Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a3dc2416 | ||
|
|
51639322e2 | ||
|
|
1f186d6d2a | ||
|
|
55e7ca9546 | ||
|
|
8f69e58aa2 | ||
|
|
1b2f2a7c79 | ||
|
|
7906248c67 | ||
|
|
22ffbf201e | ||
|
|
be499f0d66 | ||
|
|
75b2f8ccc5 | ||
|
|
4f003f1628 | ||
|
|
b3ff2b9c5e | ||
|
|
0833888eaa | ||
|
|
b626404640 | ||
|
|
0f2b5afa74 | ||
|
|
8e6cb542d9 | ||
|
|
d74fec3a5c | ||
|
|
4bee1b24f7 | ||
|
|
70605923bf | ||
|
|
424141f216 | ||
|
|
d6ea4c37d7 | ||
|
|
ebf521e64e | ||
|
|
e5fbcc2222 | ||
|
|
1b4446c828 | ||
|
|
0de3f4be7d | ||
|
|
56f2a9f7fc | ||
|
|
b081ed3c8b | ||
|
|
8a75c9155b | ||
|
|
8850984055 | ||
|
|
21176e1e0b | ||
|
|
b4cd140168 | ||
|
|
5167706d1b | ||
|
|
047f61b627 |
+5
-1
@@ -33,4 +33,8 @@ Thumbs.db
|
||||
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
backend/public/uploads/
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
backend/data/banner.json
|
||||
+162
-1
@@ -7,6 +7,164 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
@@ -69,7 +227,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,6 +0,0 @@
|
||||
{
|
||||
"message": "",
|
||||
"type": "info",
|
||||
"liveFrom": null,
|
||||
"liveTill": null
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"jobs": []
|
||||
}
|
||||
Generated
+841
-7
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.3",
|
||||
"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;
|
||||
+11
@@ -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;
|
||||
@@ -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).
|
||||
@@ -215,6 +228,7 @@ model Payment {
|
||||
amount Float
|
||||
method String
|
||||
userId String
|
||||
recordedById String?
|
||||
registrationId String?
|
||||
eventId String?
|
||||
isDonation Boolean @default(false)
|
||||
@@ -223,6 +237,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 +245,11 @@ model Payment {
|
||||
YocoTransaction YocoTransaction[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([recordedById])
|
||||
@@index([registrationId])
|
||||
@@index([eventId])
|
||||
@@index([createdAt])
|
||||
@@index([originalPaymentId])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -282,6 +299,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 +511,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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
|
||||
try {
|
||||
const includePast = req.query.includePast === 'true';
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const excludeClosed = req.query.excludeClosed === 'true';
|
||||
|
||||
const where = {};
|
||||
if (!includeInactive) where.isActive = true;
|
||||
if (!includePast) where.endDate = { gte: new Date() };
|
||||
if (excludeClosed) where.cashupStatus = { not: 'closed' };
|
||||
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -1224,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);
|
||||
@@ -1232,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();
|
||||
@@ -1253,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 {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1298,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 || '',
|
||||
@@ -1308,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
promoLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver,
|
||||
};
|
||||
const ctxHtml = {
|
||||
...ctxBase,
|
||||
@@ -1345,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 {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -1443,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);
|
||||
@@ -1451,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) {
|
||||
@@ -1474,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 {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1512,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
eventLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver: makePaymentLinkResolver(regs),
|
||||
};
|
||||
|
||||
let msg = message;
|
||||
@@ -1521,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 {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} 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
|
||||
@@ -1566,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
eventId,
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
@@ -1603,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
channel: 'whatsapp',
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,39 +692,24 @@ 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 },
|
||||
// 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: {
|
||||
registrationId,
|
||||
id: uuidv4(),
|
||||
amount: allocateAmount,
|
||||
isDonation: false
|
||||
method: payment.method,
|
||||
userId: payment.userId,
|
||||
recordedById: req.user.id,
|
||||
registrationId,
|
||||
eventId: registration.eventId,
|
||||
isDonation: false,
|
||||
originalPaymentId: payment.id,
|
||||
}
|
||||
});
|
||||
|
||||
// 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({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
amount: leftoverAmount,
|
||||
method: payment.method,
|
||||
userId: payment.userId,
|
||||
eventId: payment.eventId,
|
||||
isDonation: true,
|
||||
externalId: payment.externalId ? `${payment.externalId}-split` : null,
|
||||
status: payment.status,
|
||||
originalPaymentId: payment.id,
|
||||
createdAt: payment.createdAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (allocateAmount >= remainingAmount) {
|
||||
// Fully covers what's owed
|
||||
updatedRegistration = await prisma.registration.update({
|
||||
@@ -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);
|
||||
@@ -1151,6 +1179,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 +1188,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 +1240,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 +1254,8 @@ const getPaymentStats = async (req, res) => {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfDay
|
||||
}
|
||||
},
|
||||
...excludeDonationLegs
|
||||
}
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
@@ -1228,7 +1265,8 @@ const getPaymentStats = async (req, res) => {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: lastWeek
|
||||
}
|
||||
},
|
||||
...excludeDonationLegs
|
||||
}
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
@@ -1238,7 +1276,8 @@ const getPaymentStats = async (req, res) => {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: lastMonth
|
||||
}
|
||||
},
|
||||
...excludeDonationLegs
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -380,7 +380,7 @@ const getRegistrations = async (req, res) => {
|
||||
try {
|
||||
const registrations = await prisma.registration.findMany({
|
||||
include: {
|
||||
payments: true,
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
@@ -472,7 +472,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 +653,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 +764,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(() => {});
|
||||
}
|
||||
|
||||
@@ -1,269 +1,464 @@
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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'];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function drawHeader(doc, title, subtitle) {
|
||||
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const x = doc.page.margins.left;
|
||||
const y = doc.page.margins.top;
|
||||
const h = subtitle ? 46 : 32;
|
||||
doc.save();
|
||||
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.y = y + h + 14;
|
||||
}
|
||||
|
||||
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.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();
|
||||
});
|
||||
doc.y = rowY + boxH + 16;
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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;
|
||||
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);
|
||||
|
||||
// Header band
|
||||
if (columns.length) {
|
||||
let x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
doc.save();
|
||||
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 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Rows — zebra striped, with a highlighted tint+bold for total/summary rows
|
||||
const rows = table.rows;
|
||||
rows.forEach((row) => {
|
||||
const emphasis = isEmphasisRow(row[0]);
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
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(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(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(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).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(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(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.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
});
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// 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();
|
||||
wb.creator = 'Hope Family Church 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: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
|
||||
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
|
||||
const generatePdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, orientation } = req.body || {};
|
||||
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);
|
||||
|
||||
// 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);
|
||||
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;
|
||||
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.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Zebra rows
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 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;
|
||||
// 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;
|
||||
});
|
||||
// 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);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
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, kind, table?, layered?, subject?, body? }
|
||||
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
|
||||
const emailPdf = async (req, res) => {
|
||||
try {
|
||||
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
|
||||
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');
|
||||
}
|
||||
|
||||
// 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, '_'));
|
||||
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
|
||||
|
||||
// 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)) {
|
||||
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);
|
||||
|
||||
// Header band
|
||||
if (columns.length) {
|
||||
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);
|
||||
columns.forEach((h, i) => {
|
||||
const w = colWidths[i] || baseWidth;
|
||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||
x += w;
|
||||
});
|
||||
doc.restore();
|
||||
doc.moveDown(1.6);
|
||||
}
|
||||
|
||||
// Rows zebra
|
||||
const rows = table.rows;
|
||||
rows.forEach((row, idx) => {
|
||||
const rowY = doc.y;
|
||||
const rowH = 18;
|
||||
const bg = idx % 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();
|
||||
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.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();
|
||||
|
||||
} 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) {
|
||||
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);
|
||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica').text('No content');
|
||||
}
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM,
|
||||
// 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 +467,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 (html != null) payload.html = html;
|
||||
if (text != null) payload.text = text;
|
||||
if (message != null) payload.message = message;
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ 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 axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -273,6 +274,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 +356,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({
|
||||
@@ -405,6 +401,7 @@ const updateUserProfile = async (req, res) => {
|
||||
.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,13 +479,28 @@ const checkUserExists = async (req, res) => {
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { email: true, phoneNumber: true },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
|
||||
const hasPhone = !!existingUser?.phoneNumber;
|
||||
|
||||
res.json({
|
||||
exists: !!existingUser,
|
||||
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
|
||||
hasPhone: !!existingUser?.phoneNumber,
|
||||
hasEmail,
|
||||
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,
|
||||
notificationPreference: existingUser.notificationPreference,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -539,7 +551,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 = {
|
||||
@@ -547,7 +563,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()
|
||||
};
|
||||
|
||||
@@ -564,6 +581,7 @@ const updateUser = async (req, res) => {
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
@@ -729,6 +747,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) });
|
||||
@@ -914,6 +934,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,
|
||||
@@ -931,4 +963,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,7 +195,7 @@ 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 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
await axios.post(`${BASE}/send/text`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
message,
|
||||
});
|
||||
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,17 +272,26 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
||||
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
||||
|
||||
const { token, instanceId } = await getConfig();
|
||||
await axios.post(`${BASE}/send/pdf`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
file: {
|
||||
url: pdfUrl,
|
||||
filename: filename || 'tickets.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
},
|
||||
caption: caption || '',
|
||||
});
|
||||
try {
|
||||
await axios.post(`${BASE}/send/pdf`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
file: {
|
||||
url: pdfUrl,
|
||||
filename: filename || 'tickets.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
},
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<p className="text-gray-500 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<a href="/" className="text-blue-600 hover:underline transition-all duration-200">
|
||||
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
Back to Home →
|
||||
</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 →
|
||||
</a>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
</div>
|
||||
</div>
|
||||
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
<span className={"text-xs px-2 py-1 rounded-full font-medium " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
{isClosed ? "Closed" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,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,55 +175,57 @@ 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">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Label</th>
|
||||
<th className="py-1">Type</th>
|
||||
<th className="py-1">Ticket type</th>
|
||||
<th className="py-1">Paid from</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
<th className="py-1 text-right">Total</th>
|
||||
{!isClosed && <th className="py-1"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{costs.map(c => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
|
||||
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
<td className="py-1.5 text-right">{money(c.amount)}</td>
|
||||
<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-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
<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>
|
||||
<th className="py-1">Type</th>
|
||||
<th className="py-1">Ticket type</th>
|
||||
<th className="py-1">Paid from</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
<th className="py-1 text-right">Total</th>
|
||||
{!isClosed && <th className="py-1"></th>}
|
||||
</tr>
|
||||
))}
|
||||
{costs.length === 0 && (
|
||||
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{costs.map(c => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
|
||||
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
<td className="py-1.5 text-right">{money(c.amount)}</td>
|
||||
<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-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>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{costs.length === 0 && (
|
||||
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
{costs.length > 0 && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
|
||||
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
|
||||
{!isClosed && <td />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</tbody>
|
||||
{costs.length > 0 && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
|
||||
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
|
||||
{!isClosed && <td />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</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'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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<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 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>
|
||||
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>Back</button>
|
||||
</div>
|
||||
@@ -87,7 +93,7 @@ export default function CashupLandingPage() {
|
||||
return (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
|
||||
className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
|
||||
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -101,7 +107,7 @@ export default function CashupLandingPage() {
|
||||
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-indigo-600 shrink-0">Manage →</span>
|
||||
<span className="text-xs text-brand-600 shrink-0">Manage →</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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's what's happening with your events today.</p>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
@@ -81,158 +114,89 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>
|
||||
Manage users
|
||||
<div className="text-xs text-white/90">Create, edit, change roles and passwords</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>
|
||||
Manage events
|
||||
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
|
||||
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>
|
||||
Manage registrations
|
||||
<div className="text-xs text-white/90">Cancel, update status, and search registrations</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>
|
||||
Create manual registration
|
||||
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>
|
||||
Record payment / donations
|
||||
<div className="text-xs text-white/90">Manual payments and assignment</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>
|
||||
Open scanner
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>
|
||||
Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
|
||||
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
|
||||
Reports
|
||||
<div className="text-xs text-white/90">View, export, and email reports</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/forms")}>
|
||||
Attendee forms
|
||||
<div className="text-xs text-white/90">View submitted attendee forms</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees")}>
|
||||
Email attendees
|
||||
<div className="text-xs text-white/90">Send message to attendees of an event</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees")}>
|
||||
WhatsApp attendees
|
||||
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>
|
||||
Manage WhatsApp API
|
||||
<div className="text-xs text-white/90">Manage the WhatsApp API config</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/cashup")}>
|
||||
Post-event Cashup
|
||||
<div className="text-xs text-white/90">Set costs, reconcile takings, and close out events</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<StatCardRow>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
|
||||
</ul>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||
{overview && overview.trend.length > 0 ? (
|
||||
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Payments</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
{paymentStats ? (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Week</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Month</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
</li>
|
||||
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
|
||||
{overview && overview.topEvents.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead className="text-right">Registrations</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Tickets sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.topEvents.map(e => (
|
||||
<TableRow key={e.eventId}>
|
||||
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded p-3 bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Active events</div>
|
||||
<div className="text-lg font-semibold">{activeEventsCount}</div>
|
||||
</div>
|
||||
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Revenue today</div>
|
||||
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Donations today</div>
|
||||
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Payments overview</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<div className="flex gap-1 mb-3 bg-gray-100 rounded-lg p-1">
|
||||
{(["today", "week", "month"] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setPaymentsTab(t)}
|
||||
className={"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors " + (paymentsTab === t ? "bg-white text-brand-700 shadow-sm" : "text-gray-500 hover:text-gray-700")}
|
||||
>
|
||||
{t === "today" ? "Today" : t === "week" ? "Past week" : "Past month"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{paymentStats ? (
|
||||
<div className="text-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mt-6">
|
||||
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -157,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<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-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>
|
||||
)}
|
||||
<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="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>
|
||||
);
|
||||
router.replace("/dashboard/admin/settings?tab=whatsapp");
|
||||
}, [router]);
|
||||
return <div className="p-6 text-sm text-gray-500">Redirecting…</div>;
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepIndicator({ step }: { step: number }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Access Token" },
|
||||
{ n: 2, label: "Session Instance" },
|
||||
{ n: 3, label: "Connected" },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-0">
|
||||
{steps.map((s, i) => {
|
||||
const done = step > s.n;
|
||||
const current = step === s.n;
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
|
||||
done
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: current
|
||||
? "bg-indigo-600 border-indigo-600 text-white"
|
||||
: "bg-white border-gray-300 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : s.n}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
done || current ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
|
||||
done ? "bg-green-400" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
busyLabel,
|
||||
isBusy,
|
||||
disabled,
|
||||
color,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
isBusy: boolean;
|
||||
disabled: boolean;
|
||||
color: ButtonColor;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
|
||||
const colors: Record<ButtonColor, string> = {
|
||||
green: "bg-green-600 text-white hover:bg-green-700",
|
||||
amber: "bg-amber-500 text-white hover:bg-amber-600",
|
||||
blue: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
|
||||
};
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
|
||||
{isBusy && <Spinner />}
|
||||
{isBusy ? busyLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Ticket } from "lucide-react";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -229,10 +230,15 @@ function EventTicketsContent() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Event Tickets</h1>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Ticket className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Event Tickets</h1>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Back
|
||||
@@ -325,7 +331,7 @@ function EventTicketsContent() {
|
||||
onClick={() =>
|
||||
printTickets(filteredTickets.filter((t) => !t.isUsed))
|
||||
}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded"
|
||||
className="px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded"
|
||||
>
|
||||
Print unused
|
||||
</button>
|
||||
|
||||
@@ -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's today's scanning activity.</p>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -75,20 +80,23 @@ export default function StaffDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCard icon={Activity} label="Scans today" value={stats ? stats.totalToday : "—"} tone="brand" />
|
||||
<StatCard icon={UserCheck} label="My scans today" value={stats ? stats.myToday : "—"} tone="blue" />
|
||||
<StatCard icon={Clock} label="Last hour" value={stats ? stats.lastHour : "—"} tone="amber" />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
@@ -101,7 +109,7 @@ export default function StaffDashboardPage() {
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats…</div>}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">{stats.totalToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">My scans</div>
|
||||
<div className="text-lg font-semibold">{stats.myToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Last hour</div>
|
||||
<div className="text-lg font-semibold">{stats.lastHour}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No scans recorded yet today.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QrCode } from "lucide-react";
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Ticket Scanning</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<QrCode className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Use the button to start/stop scanning. The back camera will be used when available.
|
||||
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
|
||||
{confirmModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
|
||||
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
|
||||
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
@@ -382,7 +388,7 @@ export default function TicketScanningPage() {
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={confirmModal.remaining}
|
||||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
value={confirmQtyRaw}
|
||||
onChange={e => setConfirmQtyRaw(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -404,7 +410,7 @@ export default function TicketScanningPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
|
||||
@@ -17,6 +18,14 @@ const MODE_LABELS: Record<Mode, string> = {
|
||||
refund: "REFUND",
|
||||
};
|
||||
|
||||
const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||||
registration: UserPlus,
|
||||
payment: CreditCard,
|
||||
checkin: CheckSquare,
|
||||
tickets: Ticket,
|
||||
refund: RotateCcw,
|
||||
};
|
||||
|
||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
@@ -343,7 +352,12 @@ export default function AtTheDoorPage() {
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||
<h1 className="text-2xl font-semibold shrink-0">At The Door</h1>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<DoorOpen className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
|
||||
</div>
|
||||
<select
|
||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||||
value={eventId}
|
||||
@@ -384,21 +398,25 @@ export default function AtTheDoorPage() {
|
||||
|
||||
{/* Mode Buttons */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-4 py-2 rounded text-sm font-medium border ${
|
||||
mode === m
|
||||
? m === "refund"
|
||||
? "bg-red-600 text-white border-red-600"
|
||||
: "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
|
||||
const Icon = MODE_ICONS[m];
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded text-sm font-medium border ${
|
||||
mode === m
|
||||
? m === "refund"
|
||||
? "bg-red-600 text-white border-red-600"
|
||||
: "bg-brand-600 text-white border-brand-600"
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ✅ Panels */}
|
||||
@@ -538,7 +556,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-indigo-600 text-white">Open</button>
|
||||
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white">Open</button>
|
||||
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
|
||||
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
|
||||
</div>
|
||||
@@ -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 attendee’s registrations for the selected event.</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</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>
|
||||
<h1 className="text-2xl font-semibold">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Calendar className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
|
||||
|
||||
@@ -55,7 +56,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
{fields.map((f, idx) => (
|
||||
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium shrink-0">{typeLabel(f.type)}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
|
||||
<div className="flex-1" />
|
||||
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up">↑</button>
|
||||
@@ -109,7 +110,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => addField('text')}>+ Add question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => addField('text')}>+ Add question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
|
||||
</div>
|
||||
@@ -438,12 +439,17 @@ export default function FormsBrowserPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4 no-print">
|
||||
<h1 className="text-2xl font-semibold">Forms</h1>
|
||||
<div className="flex items-center justify-between mb-4 no-print flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<FileText className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Forms</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
{mode === 'view' && (
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={onPrint} disabled={items.length === 0}>
|
||||
Print forms
|
||||
</button>
|
||||
)}
|
||||
@@ -459,7 +465,7 @@ export default function FormsBrowserPage() {
|
||||
{/* Mode tabs */}
|
||||
<div className="mb-4 flex items-center gap-2 no-print">
|
||||
{(['view', 'fill', 'manage'] as const).map(m => (
|
||||
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
|
||||
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
|
||||
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
|
||||
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
|
||||
</label>
|
||||
@@ -511,7 +517,7 @@ export default function FormsBrowserPage() {
|
||||
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
|
||||
/>
|
||||
{userId && (
|
||||
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
|
||||
<div className="text-xs text-brand-600 mt-0.5 truncate max-w-xs">
|
||||
{allUsers.find(u => u.id === userId)?.name || userId}
|
||||
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}>✕</button>
|
||||
</div>
|
||||
@@ -521,7 +527,7 @@ export default function FormsBrowserPage() {
|
||||
{filteredUsers.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-brand-50 flex flex-col"
|
||||
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
|
||||
>
|
||||
<span className="font-medium truncate">{u.name || "(no name)"}</span>
|
||||
@@ -538,7 +544,7 @@ export default function FormsBrowserPage() {
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700"
|
||||
onClick={() => search(false)}
|
||||
disabled={loadingList}
|
||||
>
|
||||
@@ -634,7 +640,7 @@ export default function FormsBrowserPage() {
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
onClick={saveForm}
|
||||
disabled={formLoading}
|
||||
>
|
||||
@@ -719,7 +725,7 @@ export default function FormsBrowserPage() {
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
onClick={saveResponses}
|
||||
disabled={savingFill || mainTicketCount === 0}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
|
||||
@@ -61,7 +62,12 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Event ID</label>
|
||||
@@ -96,7 +102,7 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||
{busy ? "Submitting..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -198,7 +204,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
|
||||
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's what's happening with your events today.</p>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -81,52 +113,63 @@ export default function SupervisorDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
|
||||
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
|
||||
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
|
||||
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
|
||||
<div className="text-xs text-white/90">Manual payments and assignment</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
|
||||
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
|
||||
Reports
|
||||
<div className="text-xs text-white/90">View, export, and email reports</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
|
||||
Attendee forms
|
||||
<div className="text-xs text-white/90">View submitted attendee forms</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
|
||||
Email attendees
|
||||
<div className="text-xs text-white/90">Send message to attendees of an event</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
|
||||
WhatsApp attendees
|
||||
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||
{overview && overview.trend.length > 0 ? (
|
||||
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
|
||||
{overview && overview.topEvents.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead className="text-right">Registrations</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Tickets sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.topEvents.map(e => (
|
||||
<TableRow key={e.eventId}>
|
||||
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
@@ -138,21 +181,23 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Scanner activity</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
{scanStats ? (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
|
||||
@@ -169,9 +214,21 @@ export default function SupervisorDashboardPage() {
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No scanner data yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Payments today</h2>
|
||||
{paymentStats ? (
|
||||
<div className="border rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">Today</div>
|
||||
<div className="text-base font-semibold">{formatRand(paymentStats.totalToday)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
@@ -185,30 +242,6 @@ export default function SupervisorDashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded p-3 bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Active events</div>
|
||||
<div className="text-lg font-semibold">{activeEventsCount}</div>
|
||||
</div>
|
||||
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Revenue today</div>
|
||||
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Donations today</div>
|
||||
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,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>}
|
||||
@@ -439,7 +461,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,7 +527,7 @@ 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');
|
||||
}
|
||||
@@ -523,9 +546,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 +565,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 +628,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 +668,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 +763,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>
|
||||
)}
|
||||
@@ -780,7 +817,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 +827,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 +842,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 +855,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 +906,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 +924,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 +945,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 +960,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 +1013,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>
|
||||
@@ -1025,27 +1072,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 +1128,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 +1183,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 +1205,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 +1214,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"}`}>
|
||||
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</label>
|
||||
))}
|
||||
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => {
|
||||
const Icon = WA_TAB_ICONS[t];
|
||||
return (
|
||||
<label key={t} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
|
||||
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Attendees Tab ── */}
|
||||
@@ -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,32 +58,43 @@ export default function DonatePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center shrink-0">
|
||||
<HandHeart className="w-5 h-5 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
|
||||
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
|
||||
</select>
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
step="1"
|
||||
placeholder="Enter amount (min R15)"
|
||||
value={amount}
|
||||
onChange={(e)=>setAmount(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2 mb-1"
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
|
||||
</select>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
step="1"
|
||||
placeholder="Enter amount (min R15)"
|
||||
value={amount}
|
||||
onChange={(e)=>setAmount(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's what'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>
|
||||
|
||||
@@ -6,6 +6,15 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
|
||||
|
||||
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
|
||||
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
|
||||
password_changed: { label: () => "Password changed", icon: KeyRound },
|
||||
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
|
||||
};
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { user, token, logout, updateToken } = useAuth();
|
||||
@@ -91,6 +100,7 @@ export default function UserProfilePage() {
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPwMsg({ type: "ok", text: "Password changed." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
|
||||
} finally {
|
||||
@@ -123,6 +133,25 @@ export default function UserProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Account activity ──────────────────────────────────────────────────────
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [loadingActivity, setLoadingActivity] = useState(false);
|
||||
|
||||
const loadActivity = async () => {
|
||||
if (!token) return;
|
||||
setLoadingActivity(true);
|
||||
try {
|
||||
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
|
||||
setActivity(Array.isArray(res) ? res : []);
|
||||
} catch (e) {
|
||||
// Non-critical — just leave the list empty rather than showing an error banner.
|
||||
} finally {
|
||||
setLoadingActivity(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
|
||||
// ── Account closure ───────────────────────────────────────────────────────
|
||||
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
@@ -153,214 +182,214 @@ export default function UserProfilePage() {
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
|
||||
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
|
||||
|
||||
const SectionHeader = ({ icon: Icon, title, tone = "brand" }: { icon: typeof User; title: string; tone?: "brand" | "amber" | "red" }) => {
|
||||
const toneCls = tone === "amber" ? "bg-amber-50 text-amber-600" : tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600";
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${toneCls}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
|
||||
<h1 className="text-2xl font-semibold">Profile & Security</h1>
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Profile & Security</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Profile info ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
placeholder="e.g. 082 123 4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-2 gap-6 items-start">
|
||||
<div className="space-y-6">
|
||||
{/* ── Profile info ─────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={User} title="Personal information" />
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={notifPref}
|
||||
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
|
||||
>
|
||||
<option value="email">Email only</option>
|
||||
<option value="whatsapp">WhatsApp only</option>
|
||||
<option value="both">Email & WhatsApp</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
|
||||
</div>
|
||||
)}
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
|
||||
<option value="email">Email only</option>
|
||||
<option value="whatsapp">WhatsApp only</option>
|
||||
<option value="both">Email & WhatsApp</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingProfile ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
{profileMsg && <Alert msg={profileMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Password ─────────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Change password</h2>
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPw}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingPw ? "Changing…" : "Change password"}
|
||||
</button>
|
||||
{pwMsg && <Alert msg={pwMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-1">Security</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
|
||||
You will remain logged in on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={revokeSessions}
|
||||
disabled={revoking}
|
||||
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? "Signing out…" : "Sign out all other devices"}
|
||||
</button>
|
||||
{revokeMsg && <Alert msg={revokeMsg} />}
|
||||
</section>
|
||||
|
||||
{/* ── Danger zone ──────────────────────────────────────────────────── */}
|
||||
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Closing your account will deactivate it immediately. You can also request that your personal
|
||||
information (name, email, phone number) be permanently deleted. Tickets and payment records
|
||||
will remain for accounting purposes but will show as "Deleted User".
|
||||
</p>
|
||||
|
||||
{closeStep === "idle" && (
|
||||
<button
|
||||
onClick={() => setCloseStep("confirm")}
|
||||
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
|
||||
>
|
||||
Close my account…
|
||||
</button>
|
||||
)}
|
||||
|
||||
{closeStep === "confirm" && (
|
||||
<form onSubmit={submitAccountClosure} className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={deleteData}
|
||||
onChange={e => setDeleteData(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Also delete my personal data</span> — your name, email address,
|
||||
and phone number will be permanently removed and cannot be recovered.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm with your password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
value={closePassword}
|
||||
onChange={e => setClosePassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password to confirm"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
<button type="submit" disabled={savingProfile} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingProfile ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={closing || !closePassword}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
|
||||
{profileMsg && <Alert msg={profileMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Account activity ─────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={Clock} title="Account activity" />
|
||||
{loadingActivity && activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{activity.map(ev => {
|
||||
const meta = ACTIVITY_META[ev.type] || ACTIVITY_META.login;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<li key={ev.id} className="flex items-start gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
|
||||
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ── Password ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={Lock} title="Change password" />
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
|
||||
</div>
|
||||
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingPw ? "Changing…" : "Change password"}
|
||||
</button>
|
||||
</div>
|
||||
{closeMsg && <Alert msg={closeMsg} />}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
{pwMsg && <Alert msg={pwMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
|
||||
You will remain logged in on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={revokeSessions}
|
||||
disabled={revoking}
|
||||
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? "Signing out…" : "Sign out all other devices"}
|
||||
</button>
|
||||
{revokeMsg && <Alert msg={revokeMsg} />}
|
||||
</section>
|
||||
|
||||
{/* ── Danger zone ──────────────────────────────────────────────── */}
|
||||
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={Trash2} title="Close account" tone="red" />
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Closing your account will deactivate it immediately. You can also request that your personal
|
||||
information (name, email, phone number) be permanently deleted. Tickets and payment records
|
||||
will remain for accounting purposes but will show as "Deleted User".
|
||||
</p>
|
||||
|
||||
{closeStep === "idle" && (
|
||||
<button
|
||||
onClick={() => setCloseStep("confirm")}
|
||||
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
|
||||
>
|
||||
Close my account…
|
||||
</button>
|
||||
)}
|
||||
|
||||
{closeStep === "confirm" && (
|
||||
<form onSubmit={submitAccountClosure} className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={deleteData}
|
||||
onChange={e => setDeleteData(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Also delete my personal data</span> — your name, email address,
|
||||
and phone number will be permanently removed and cannot be recovered.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm with your password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
value={closePassword}
|
||||
onChange={e => setClosePassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter your password to confirm"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={closing || !closePassword}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
|
||||
</button>
|
||||
</div>
|
||||
{closeMsg && <Alert msg={closeMsg} />}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { token } = useAuth();
|
||||
@@ -43,10 +44,15 @@ export default function ResetPasswordPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Reset password</h1>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<KeyRound className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
|
||||
</div>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Back to dashboard</button>
|
||||
</div>
|
||||
@@ -96,7 +102,7 @@ export default function ResetPasswordPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || loading}
|
||||
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
|
||||
className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60"
|
||||
>{loading ? "Saving…" : "Update password"}</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
console.error("Error sharing:", err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share
|
||||
@@ -38,7 +38,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
console.error("Error generating QR code:", err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
Save QR
|
||||
|
||||
@@ -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,138 +103,159 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-10 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500">{formatDateTimeRange(event.startDate, event.endDate)}</p>
|
||||
<ClientActions event={event} />
|
||||
{event.picture && (
|
||||
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded" />
|
||||
)}
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
{event.attachments && event.attachments.length > 0 && (
|
||||
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{event.picture ? (
|
||||
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded-xl" />
|
||||
) : (
|
||||
<div className="w-full aspect-video rounded-xl bg-brand-50 flex items-center justify-center">
|
||||
<Calendar className="w-12 h-12 text-brand-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mt-6">Downloads</h2>
|
||||
<ul className="list-disc pl-5 text-gray-700">
|
||||
{event.attachments.map((att) => (
|
||||
<li key={att.id}>
|
||||
<a className="text-blue-600 hover:underline" href={att.url} target="_blank" rel="noopener noreferrer">
|
||||
{att.originalName}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
|
||||
<Calendar className="w-4 h-4 shrink-0" />
|
||||
{formatDateTimeRange(event.startDate, event.endDate)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ClientActions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold mt-6">Tickets</h2>
|
||||
<div className="space-y-2">
|
||||
{(event.eventOptions || []).map((opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
|
||||
|
||||
if (hasVariants) {
|
||||
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
|
||||
return (
|
||||
<div key={opt.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
||||
<span className="font-medium text-sm">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{priceLabel}</span>
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
|
||||
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
|
||||
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
|
||||
return (
|
||||
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
|
||||
<span>{v.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
|
||||
{vSoldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
|
||||
// Early bird pricing display
|
||||
const now = new Date();
|
||||
const activeTiers = (opt.earlyBirdTiers || [])
|
||||
.filter((t) => now < new Date(t.deadline))
|
||||
.sort((a, b) => a.price - b.price);
|
||||
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
|
||||
|
||||
return (
|
||||
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
|
||||
<span className="font-medium">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTiers.length > 0 ? (
|
||||
<>
|
||||
<span className="text-indigo-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
|
||||
<span className="text-xs text-indigo-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||||
)}
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
{event.attachments && event.attachments.length > 0 && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Paperclip className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Downloads</h2>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ul className="space-y-1.5">
|
||||
{event.attachments.map((att) => (
|
||||
<li key={att.id}>
|
||||
<a className="text-brand-600 hover:underline text-sm" href={att.url} target="_blank" rel="noopener noreferrer">
|
||||
{att.originalName}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */}
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm lg:hidden">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ticket className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||||
</div>
|
||||
<TicketList event={event} />
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm sticky top-20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ticket className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||||
</div>
|
||||
<TicketList event={event} />
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
|
||||
const notYetOpen = goLive ? now < goLive : false;
|
||||
const closed = (deadline ? now >= deadline : false) || now >= end;
|
||||
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
|
||||
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||||
if (notYetOpen) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration not yet open">
|
||||
Opens {goLive?.toLocaleString()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (closed) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration closed">
|
||||
Registration closed
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (eventSoldOut) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||||
Sold Out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={`/register/${event.id}`} className="inline-block bg-blue-600 text-white px-4 py-2 rounded">
|
||||
Register
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketList({ event }: { event: Event }) {
|
||||
const options = event.eventOptions || [];
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
|
||||
<p className="text-sm text-gray-400">Free entry — no tickets required.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
|
||||
|
||||
if (hasVariants) {
|
||||
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
|
||||
return (
|
||||
<div key={opt.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
||||
<span className="font-medium text-sm">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{priceLabel}</span>
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
|
||||
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
|
||||
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
|
||||
return (
|
||||
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
|
||||
<span>{v.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
|
||||
{vSoldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Early bird pricing display
|
||||
const now = new Date();
|
||||
const activeTiers = (opt.earlyBirdTiers || [])
|
||||
.filter((t) => now < new Date(t.deadline))
|
||||
.sort((a, b) => a.price - b.price);
|
||||
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
|
||||
|
||||
return (
|
||||
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
|
||||
<span className="font-medium">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTiers.length > 0 ? (
|
||||
<>
|
||||
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
|
||||
<span className="text-xs text-brand-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||||
)}
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
@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
|
||||
* and --ring are overridden to the site's fixed indigo/purple brand color
|
||||
* (matches the `brand` scale in tailwind.config.js, brand-600 #4F46E5).
|
||||
*
|
||||
* IMPORTANT: the org's admin-configurable accent color
|
||||
* (SiteSettingsContext.settings.accent_color) must NEVER be wired into any
|
||||
* variable here or into any Tailwind color token. It is applied in exactly
|
||||
* one place — an inline `style={{ color }}` on the org name text in
|
||||
* Navbar.tsx — and nowhere else. Every other themed element (buttons, links,
|
||||
* active nav states, icon chips, the help button, etc.) uses the fixed
|
||||
* brand/primary tokens below regardless of what the org sets that setting to.
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SiteSettingsProvider } 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";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -37,6 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<SetupGuard />
|
||||
<BannerBar />
|
||||
{children}
|
||||
<HelpFab />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</SiteSettingsProvider>
|
||||
|
||||
@@ -46,8 +46,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 +59,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'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 +210,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 +220,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 +231,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>
|
||||
|
||||
@@ -44,8 +44,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 +57,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 +71,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 +188,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 +196,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] || ""}
|
||||
|
||||
+80
-22
@@ -1,10 +1,8 @@
|
||||
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 { appName } from "@/lib/siteConfig";
|
||||
|
||||
import { Calendar, UserPlus, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -20,6 +18,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 +39,87 @@ export default async function HomePage() {
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1">
|
||||
<section className="bg-gradient-to-br from-blue-100 to-white py-16 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to {appName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-6">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="space-x-4">
|
||||
<a href="/events" className="px-6 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700">
|
||||
View Events
|
||||
</a>
|
||||
<a href="/register" className="px-6 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50">
|
||||
Join Us
|
||||
</a>
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-brand-50 via-white to-brand-50 py-20 text-center">
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-brand-100/60 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 w-96 h-96 rounded-full bg-brand-100/40 blur-3xl" />
|
||||
</div>
|
||||
<div className="relative max-w-2xl mx-auto px-4">
|
||||
{sorted.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full bg-white border border-brand-200 text-brand-700 shadow-sm mb-5">
|
||||
<CalendarCheck className="w-3.5 h-3.5" />
|
||||
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
View Events
|
||||
</a>
|
||||
<a href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Join Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 px-4 max-w-7xl mx-auto">
|
||||
<SectionHeader title="Upcoming Events" />
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{sorted.slice(0, 3).map(event => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-center mt-8">
|
||||
<a href="/events" className="text-blue-600 hover:underline text-sm">
|
||||
View all events →
|
||||
{sorted.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50">
|
||||
<CalendarCheck className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No upcoming events right now — check back soon.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{sorted.slice(0, 3).map(event => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mt-8 sm:hidden">
|
||||
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactSection />
|
||||
<section className="bg-gray-50 py-14 px-4">
|
||||
<div className="max-w-6xl mx-auto grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map(f => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<div key={f.title} className="text-center sm:text-left">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-50 flex items-center justify-center mb-3 mx-auto sm:mx-0">
|
||||
<Icon className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{f.title}</h3>
|
||||
<p className="text-sm text-gray-600">{f.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-brand-50 mb-4">
|
||||
<ShieldCheck className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Secure & reliable</h2>
|
||||
<p className="text-sm text-gray-600">Your details and payments are handled securely from registration through to the day of the event.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export default function PaymentSuccessPage() {
|
||||
@@ -14,25 +15,32 @@ export default function PaymentSuccessPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
|
||||
<h1 className="text-2xl font-semibold mb-2">Payment successful</h1>
|
||||
<p className="text-gray-700">
|
||||
Thank you! Your payment was received. Your tickets will be sent to you shortly.
|
||||
{contactEmail && (
|
||||
<>
|
||||
{" "}If you have any questions, contact us at{" "}
|
||||
<a href={`mailto:${contactEmail}`} className="text-blue-600 hover:underline">
|
||||
{contactEmail}
|
||||
</a>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className="mt-4 px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm"
|
||||
onClick={() => router.push('/dashboard/user')}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Payment successful</h1>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Thank you! Your payment was received. Your tickets will be sent to you shortly.
|
||||
{contactEmail && (
|
||||
<>
|
||||
{" "}If you have any questions, contact us at{" "}
|
||||
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">
|
||||
{contactEmail}
|
||||
</a>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors"
|
||||
onClick={() => router.push('/dashboard/user')}
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,7 @@ function OptionCard({
|
||||
{tier ? (
|
||||
<>
|
||||
<span>R{price.toFixed(2)}</span>
|
||||
<span className="text-indigo-600">
|
||||
<span className="text-brand-600">
|
||||
Early bird — ends {new Date(tier.deadline).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" })}
|
||||
</span>
|
||||
</>
|
||||
@@ -417,11 +417,11 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
<div className="text-base font-semibold mb-1">Your details</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
No account needed for this event.{" "}
|
||||
<a href={`/login?redirect=/register/${eventId}`} className="text-indigo-600 hover:underline">Log in</a> if you already have one.
|
||||
<a href={`/login?redirect=/register/${eventId}`} className="text-brand-600 hover:underline">Log in</a> if you already have one.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Full name *"
|
||||
value={guestName}
|
||||
onChange={e => setGuestName(e.target.value)}
|
||||
@@ -429,7 +429,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Email address *"
|
||||
value={guestEmail}
|
||||
onChange={e => setGuestEmail(e.target.value)}
|
||||
@@ -437,7 +437,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Phone number (optional)"
|
||||
value={guestPhone}
|
||||
onChange={e => setGuestPhone(e.target.value)}
|
||||
@@ -486,7 +486,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
<button
|
||||
onClick={submitRegistration}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
{creatingRegistration ? "Registering..." : "Register"}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
|
||||
@@ -116,7 +117,12 @@ function RegistrationSuccessContent() {
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||
<div className="text-2xl font-semibold mb-2">Registration successful</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Registration successful</h1>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">Thank you! Your registration has been created{registrationId ? ` (#${registrationId.slice(0,8)})` : ""}.</p>
|
||||
{form?.isRequired && reg ? (
|
||||
<div className="mb-6 p-3 border rounded bg-yellow-50 text-yellow-800">
|
||||
@@ -255,7 +261,7 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -37,7 +38,12 @@ function ResetPasswordContent() {
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||
<h1 className="text-2xl font-semibold mb-4">Reset your password</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<KeyRound className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reset your password</h1>
|
||||
</div>
|
||||
{!token && <p className="text-red-600 mb-4">Missing or invalid token.</p>}
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
@@ -49,7 +55,7 @@ function ResetPasswordContent() {
|
||||
<label className="block text-sm font-medium mb-1">Confirm Password</label>
|
||||
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
</div>
|
||||
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60">
|
||||
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full font-medium transition-colors">
|
||||
{loading ? "Saving..." : "Set new password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -85,6 +85,43 @@ function fmtCurrency(val: number) {
|
||||
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function prefLabel(pref: "email" | "whatsapp" | "both") {
|
||||
return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
|
||||
}
|
||||
|
||||
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
|
||||
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
|
||||
function looksLikePhone(s: string): boolean {
|
||||
const digits = s.replace(/\D/g, "");
|
||||
return digits.length >= 9 && digits.length <= 11;
|
||||
}
|
||||
|
||||
// Show/hide toggle rendered inside a password input — absolutely positioned on its
|
||||
// right edge, so callers must wrap the input in a `relative` container and add
|
||||
// enough right padding (`pr-12`) for it not to overlap the typed text.
|
||||
function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
|
||||
aria-label={shown ? "Hide password" : "Show password"}
|
||||
>
|
||||
{shown ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
|
||||
export default function SelfServicePage() {
|
||||
const [screen, setScreen] = useState<Screen>("setup");
|
||||
@@ -92,6 +129,7 @@ export default function SelfServicePage() {
|
||||
// ── Setup state ─────────────────────────────────────────────────
|
||||
const [setupEmail, setSetupEmail] = useState("");
|
||||
const [setupPassword, setSetupPassword] = useState("");
|
||||
const [showSetupPassword, setShowSetupPassword] = useState(false);
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
@@ -102,6 +140,7 @@ export default function SelfServicePage() {
|
||||
// ── Change-event modal ───────────────────────────────────────────
|
||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||
const [changePassword, setChangePassword] = useState("");
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [changeError, setChangeError] = useState<string | null>(null);
|
||||
const [changeLoading, setChangeLoading] = useState(false);
|
||||
|
||||
@@ -112,9 +151,23 @@ export default function SelfServicePage() {
|
||||
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [createAccount, setCreateAccount] = useState(false);
|
||||
const [visitorPassword, setVisitorPassword] = useState("");
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const [checkingAccount, setCheckingAccount] = useState(false);
|
||||
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// ── Account lookup (automatic, triggered when email/phone is entered) ──
|
||||
// Email and phone are resolved to an existing account independently. If they
|
||||
// resolve to the SAME account, that account is "matched". If they resolve to
|
||||
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
|
||||
// of silently overwriting one field's details with the other's account.
|
||||
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
|
||||
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
|
||||
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
|
||||
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
|
||||
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
|
||||
const accountExists = !!matchedAccount;
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
|
||||
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -175,12 +228,39 @@ export default function SelfServicePage() {
|
||||
}, 0);
|
||||
}, [selectedEvent, quantities]);
|
||||
|
||||
// Differences between what's on file for the matched account and what's currently
|
||||
// typed in the form — shown to the operator for confirmation before anything is saved.
|
||||
const pendingChanges = useMemo(() => {
|
||||
if (!matchedAccount) return [];
|
||||
const changes: { field: string; from: string; to: string }[] = [];
|
||||
const name = visitorName.trim();
|
||||
const email = visitorEmail.trim();
|
||||
const phone = visitorPhone.trim();
|
||||
if (name && name !== matchedAccount.name) {
|
||||
changes.push({ field: "Name", from: matchedAccount.name, to: name });
|
||||
}
|
||||
if (email && email !== (matchedAccount.email || "")) {
|
||||
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
|
||||
}
|
||||
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
|
||||
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
|
||||
}
|
||||
if (notificationPref !== matchedAccount.notificationPreference) {
|
||||
changes.push({
|
||||
field: "Send tickets via",
|
||||
from: prefLabel(matchedAccount.notificationPreference),
|
||||
to: prefLabel(notificationPref),
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
|
||||
|
||||
// ─── Load events after supervisor login ─────────────────────────
|
||||
const loadEvents = useCallback(async (token: string) => {
|
||||
setEventsLoading(true);
|
||||
try {
|
||||
const data: KioskEvent[] = await apiFetch(
|
||||
"/api/events/all?includePast=false&includeInactive=false",
|
||||
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
|
||||
{ authToken: token }
|
||||
);
|
||||
setEvents(data);
|
||||
@@ -262,7 +342,9 @@ export default function SelfServicePage() {
|
||||
}
|
||||
|
||||
// ─── Visitor registration ────────────────────────────────────────
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
// Validates the form and, if the typed name/email/phone differ from the matched
|
||||
// account's details, shows a confirmation modal before anything is saved.
|
||||
function handleRegisterSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
|
||||
@@ -277,7 +359,18 @@ export default function SelfServicePage() {
|
||||
setFormError("No event selected.");
|
||||
return;
|
||||
}
|
||||
if (accountConflict) {
|
||||
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
|
||||
return;
|
||||
}
|
||||
if (pendingChanges.length > 0) {
|
||||
setShowUpdateConfirm(true);
|
||||
return;
|
||||
}
|
||||
handleRegister();
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -415,7 +508,12 @@ export default function SelfServicePage() {
|
||||
setNotificationPref("email");
|
||||
setCreateAccount(false);
|
||||
setVisitorPassword("");
|
||||
setAccountExists(false);
|
||||
setShowVisitorPassword(false);
|
||||
setEmailMatch(null);
|
||||
setPhoneMatch(null);
|
||||
appliedMatchIdRef.current = null;
|
||||
setShowUpdateConfirm(false);
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
@@ -431,33 +529,67 @@ export default function SelfServicePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Check whether an account already exists for the entered email/phone ──
|
||||
useEffect(() => {
|
||||
const email = visitorEmail.trim();
|
||||
const phone = visitorPhone.trim();
|
||||
if (!supervisorToken || (!email && !phone)) {
|
||||
setAccountExists(false);
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
setCheckingAccount(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (email) params.set("email", email);
|
||||
if (phone) params.set("phone", phone);
|
||||
const data = await apiFetch<{ exists: boolean }>(
|
||||
`/api/users/check-exists?${params.toString()}`,
|
||||
{ authToken: supervisorToken }
|
||||
);
|
||||
setAccountExists(!!data?.exists);
|
||||
} catch {
|
||||
setAccountExists(false);
|
||||
} finally {
|
||||
setCheckingAccount(false);
|
||||
// ─── Automatic account lookup — runs once the operator finishes entering the
|
||||
// email or phone field (on blur), never on keystroke. Matches exactly against
|
||||
// that one value and only ever returns that one matched account (or nothing) —
|
||||
// never a broader/fuzzy match. Email and phone are tracked as two independent
|
||||
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
|
||||
// "last found account" — that's what previously let changing the phone number
|
||||
// silently pull in and overwrite the form with an unrelated account's details. ──
|
||||
async function performLookup(kind: "email" | "phone", rawValue: string) {
|
||||
const value = rawValue.trim();
|
||||
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
|
||||
if (!value || !supervisorToken) { setMatch(null); return; }
|
||||
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
|
||||
|
||||
setLookupLoading(true);
|
||||
setLookupMessage(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (kind === "email") params.set("email", value);
|
||||
else params.set("phone", value);
|
||||
const data = await apiFetch<{
|
||||
exists: boolean;
|
||||
user?: MatchedAccount | null;
|
||||
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||
if (data?.exists && data.user) {
|
||||
setMatch(data.user);
|
||||
setLookupMessage("Account found — details filled in below.");
|
||||
} else {
|
||||
setMatch(null);
|
||||
setLookupMessage(null);
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [visitorEmail, visitorPhone, supervisorToken]);
|
||||
} catch {
|
||||
setLookupMessage("Lookup failed. Please try again.");
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmailBlur() {
|
||||
performLookup("email", visitorEmail);
|
||||
}
|
||||
|
||||
function handlePhoneBlur() {
|
||||
performLookup("phone", visitorPhone);
|
||||
}
|
||||
|
||||
// Autofill Name/Email/Phone/preference from the matched account — but only once per
|
||||
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
|
||||
// quantity button) blurs whatever field was last active and re-runs its lookup;
|
||||
// that returns the same account as a new object each time, and keying this off
|
||||
// object identity instead of id made it re-fire and stomp every field — including
|
||||
// ones the operator had already deliberately edited — back to the account's
|
||||
// original values on every unrelated tap.
|
||||
const appliedMatchIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
|
||||
appliedMatchIdRef.current = matchedAccount.id;
|
||||
setVisitorName(matchedAccount.name);
|
||||
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
|
||||
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
|
||||
setNotificationPref(matchedAccount.notificationPreference);
|
||||
}, [matchedAccount]);
|
||||
|
||||
// Existing accounts are linked automatically — never show the "create account" toggle for them
|
||||
useEffect(() => {
|
||||
@@ -494,15 +626,18 @@ export default function SelfServicePage() {
|
||||
<form onSubmit={handleChangeEvent} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showChangePassword ? "text" : "password"}
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
@@ -516,7 +651,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changeLoading}
|
||||
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{changeLoading ? "Verifying…" : "Confirm"}
|
||||
</button>
|
||||
@@ -543,7 +678,7 @@ export default function SelfServicePage() {
|
||||
type="email"
|
||||
value={setupEmail}
|
||||
onChange={(e) => setSetupEmail(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="supervisor@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
@@ -551,21 +686,24 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSetupPassword ? "text" : "password"}
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-60 transition mt-2"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
|
||||
>
|
||||
{setupLoading ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
@@ -585,7 +723,7 @@ export default function SelfServicePage() {
|
||||
<select
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">— Select an event —</option>
|
||||
{events.map((ev) => (
|
||||
@@ -597,9 +735,9 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
|
||||
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-sm text-brand-800">
|
||||
<p className="font-semibold">{selectedEvent.title}</p>
|
||||
<p className="text-blue-600">{fmtDate(selectedEvent.startDate)} — {fmtDate(selectedEvent.endDate)}</p>
|
||||
<p className="text-brand-600">{fmtDate(selectedEvent.startDate)} — {fmtDate(selectedEvent.endDate)}</p>
|
||||
{selectedEvent.form?.isRequired && (
|
||||
<p className="text-orange-600 mt-1 text-xs font-medium">This event requires an attendee form to be completed at registration.</p>
|
||||
)}
|
||||
@@ -608,7 +746,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
onClick={handleStartKiosk}
|
||||
disabled={!selectedEventId}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-50 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-50 transition"
|
||||
>
|
||||
Start Kiosk
|
||||
</button>
|
||||
@@ -628,18 +766,7 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
@@ -656,7 +783,8 @@ export default function SelfServicePage() {
|
||||
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
|
||||
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onBlur={handleEmailBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -675,11 +803,40 @@ export default function SelfServicePage() {
|
||||
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
|
||||
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onBlur={handlePhoneBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="+27 82 000 0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lookupLoading && (
|
||||
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account…</p>
|
||||
)}
|
||||
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
|
||||
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
|
||||
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
|
||||
one of these fields before continuing.
|
||||
</div>
|
||||
)}
|
||||
{!lookupLoading && !accountConflict && lookupMessage && (
|
||||
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||
{lookupMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preference selector — only shown when both channels are available */}
|
||||
{visitorEmail.trim() && visitorPhone.trim() && (
|
||||
<div>
|
||||
@@ -693,7 +850,7 @@ export default function SelfServicePage() {
|
||||
className={`flex-1 py-3 transition-colors ${
|
||||
notificationPref === p
|
||||
? p === "whatsapp" ? "bg-green-600 text-white"
|
||||
: p === "both" ? "bg-indigo-600 text-white"
|
||||
: p === "both" ? "bg-brand-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
@@ -744,7 +901,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
|
||||
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -775,7 +932,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
|
||||
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -802,28 +959,29 @@ export default function SelfServicePage() {
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
onClick={() => setCreateAccount((v) => !v)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Create an account</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||
</div>
|
||||
</label>
|
||||
{createAccount && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showVisitorPassword ? "text" : "password"}
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -837,8 +995,8 @@ export default function SelfServicePage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
disabled={formLoading || accountConflict}
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
|
||||
</button>
|
||||
@@ -847,6 +1005,43 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
|
||||
{showUpdateConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
These details differ from what's on file for this account. Confirm to update them.
|
||||
</p>
|
||||
<div className="space-y-3 mb-6">
|
||||
{pendingChanges.map((c) => (
|
||||
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
|
||||
<p className="text-sm text-gray-400 line-through">{c.from}</p>
|
||||
<p className="text-sm text-green-700 font-medium">{c.to}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpdateConfirm(false)}
|
||||
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
|
||||
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 transition"
|
||||
>
|
||||
Confirm & Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
||||
{screen === "eventform" && selectedEvent?.form && (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
@@ -856,7 +1051,7 @@ export default function SelfServicePage() {
|
||||
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
|
||||
{mainTicketCount > 1 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-base font-semibold text-blue-700">
|
||||
<p className="text-base font-semibold text-brand-700">
|
||||
Attendee {currentTicketIndex + 1} of {mainTicketCount}
|
||||
</p>
|
||||
<div className="flex gap-1.5 justify-center mt-2">
|
||||
@@ -867,7 +1062,7 @@ export default function SelfServicePage() {
|
||||
i < currentTicketIndex
|
||||
? "w-6 bg-green-500"
|
||||
: i === currentTicketIndex
|
||||
? "w-6 bg-blue-600"
|
||||
? "w-6 bg-brand-600"
|
||||
: "w-6 bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
@@ -901,7 +1096,7 @@ export default function SelfServicePage() {
|
||||
checked={currentTicketAnswers[field.id] === opt}
|
||||
onChange={() => setCurrentTicketAnswers(a => ({ ...a, [field.id]: opt }))}
|
||||
required={field.isRequired && !currentTicketAnswers[field.id]}
|
||||
className="accent-blue-600"
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="text-base">{opt}</span>
|
||||
</label>
|
||||
@@ -913,7 +1108,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
) : field.type === "date" ? (
|
||||
<input
|
||||
@@ -921,7 +1116,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
) : field.type === "paragraph" ? (
|
||||
<textarea
|
||||
@@ -929,7 +1124,7 @@ export default function SelfServicePage() {
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -937,7 +1132,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -952,7 +1147,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formSubmitLoading}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{formSubmitLoading
|
||||
? "Submitting…"
|
||||
@@ -980,7 +1175,7 @@ export default function SelfServicePage() {
|
||||
<p className="text-lg text-gray-600 mb-3">Welcome, <span className="font-semibold">{confirmationName}</span>!</p>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
<p className="text-blue-700 font-medium mb-4">{selectedEvent.title}</p>
|
||||
<p className="text-brand-700 font-medium mb-4">{selectedEvent.title}</p>
|
||||
)}
|
||||
|
||||
{registrationIsFree ? (
|
||||
@@ -994,7 +1189,7 @@ export default function SelfServicePage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-blue-800 text-sm mb-5 text-left">
|
||||
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-brand-800 text-sm mb-5 text-left">
|
||||
<p className="font-semibold mb-1">
|
||||
{notificationPref === "whatsapp"
|
||||
? "Payment instructions have been sent to your WhatsApp."
|
||||
@@ -1002,12 +1197,12 @@ export default function SelfServicePage() {
|
||||
? "Payment instructions have been sent via email and WhatsApp."
|
||||
: "Payment instructions have been emailed to you."}
|
||||
</p>
|
||||
<p className="text-blue-700">Payment can also be made at the door — cash and card accepted.</p>
|
||||
<p className="text-brand-700">Payment can also be made at the door — cash and card accepted.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-blue-200 bg-blue-50 text-2xl font-bold text-blue-700">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-brand-200 bg-brand-50 text-2xl font-bold text-brand-700">
|
||||
{countdown}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Returning to registration in {countdown}s</p>
|
||||
@@ -1015,7 +1210,7 @@ export default function SelfServicePage() {
|
||||
|
||||
<button
|
||||
onClick={resetAndGoToForm}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 transition"
|
||||
>
|
||||
OK — Next Person
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Megaphone } from "lucide-react";
|
||||
|
||||
type BannerType = "info" | "warning" | "success" | "danger";
|
||||
|
||||
@@ -107,8 +108,13 @@ export default function SetBannerPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-start justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg border rounded-xl bg-white shadow-sm p-6">
|
||||
<h1 className="text-xl font-semibold mb-1">Site Banner</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">Set a message that appears at the top of every page for visitors within the scheduled window.</p>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Megaphone className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Site Banner</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6 mt-2">Set a message that appears at the top of every page for visitors within the scheduled window.</p>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
@@ -147,7 +153,7 @@ export default function SetBannerPage() {
|
||||
{status && <p className="text-sm text-emerald-700">{status}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button type="submit" disabled={busy} className="px-4 py-2 rounded bg-indigo-600 text-white text-sm disabled:opacity-60 hover:bg-indigo-700">
|
||||
<button type="submit" disabled={busy} className="px-4 py-2 rounded bg-brand-600 text-white text-sm disabled:opacity-60 hover:bg-brand-700">
|
||||
{busy ? "Saving…" : "Save banner"}
|
||||
</button>
|
||||
<button type="button" onClick={clear} disabled={busy} className="px-4 py-2 rounded bg-gray-100 text-gray-700 text-sm disabled:opacity-60 hover:bg-gray-200">
|
||||
|
||||
@@ -15,7 +15,7 @@ function ProgressBar({ step }: { step: Step }) {
|
||||
<React.Fragment key={s}>
|
||||
<div
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${
|
||||
s < step ? "bg-indigo-600" : s === step ? "bg-indigo-400" : "bg-gray-200"
|
||||
s < step ? "bg-brand-600" : s === step ? "bg-brand-400" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
</React.Fragment>
|
||||
@@ -40,7 +40,7 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
@@ -224,13 +224,13 @@ export default function SetupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-100 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-brand-50 to-brand-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
|
||||
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-indigo-100 mb-3">
|
||||
<svg className="w-7 h-7 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-brand-100 mb-3">
|
||||
<svg className="w-7 h-7 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -282,7 +282,7 @@ export default function SetupPage() {
|
||||
</Field>
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">
|
||||
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
@@ -316,7 +316,7 @@ export default function SetupPage() {
|
||||
|
||||
<div className="pt-2 flex justify-between">
|
||||
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100">← Back</button>
|
||||
<button type="submit" disabled={loading} className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||
<button type="submit" disabled={loading} className="px-5 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||
{loading ? "Creating account…" : "Next →"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -356,7 +356,7 @@ export default function SetupPage() {
|
||||
|
||||
<div className="pt-2 flex justify-between">
|
||||
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100">← Back</button>
|
||||
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next →</button>
|
||||
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">Next →</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
@@ -404,7 +404,7 @@ export default function SetupPage() {
|
||||
|
||||
<div className="pt-2 flex justify-between">
|
||||
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100">← Back</button>
|
||||
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next →</button>
|
||||
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">Next →</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
@@ -487,7 +487,7 @@ export default function SetupPage() {
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => handleFinish(false)}
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
className="px-5 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{loading ? "Setting up…" : "Finish setup"}
|
||||
</button>
|
||||
@@ -515,7 +515,7 @@ export default function SetupPage() {
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="mt-4 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium"
|
||||
className="mt-4 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Go to login
|
||||
</button>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function LoginForm() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-blue-600"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-brand-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
@@ -93,7 +93,7 @@ export function LoginForm() {
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
@@ -101,7 +101,7 @@ export function LoginForm() {
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href={registerHref}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Don't have an account? Sign up here.
|
||||
</a>
|
||||
@@ -120,7 +120,7 @@ export function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white rounded py-2 disabled:opacity-60"
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
@@ -130,7 +130,7 @@ export function LoginForm() {
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
Terms of Use
|
||||
</Link>{" "}
|
||||
@@ -138,7 +138,7 @@ export function LoginForm() {
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>.
|
||||
|
||||
@@ -134,7 +134,7 @@ export function RegisterForm() {
|
||||
type="checkbox"
|
||||
checked={accepted}
|
||||
onChange={(e) => setAccepted(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 border-gray-300 rounded accent-blue-600"
|
||||
className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
|
||||
@@ -142,7 +142,7 @@ export function RegisterForm() {
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
Terms of Use
|
||||
</Link>{" "}
|
||||
@@ -150,7 +150,7 @@ export function RegisterForm() {
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
target="_blank"
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>.
|
||||
@@ -160,7 +160,7 @@ export function RegisterForm() {
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href={loginHref}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Already have an account? Sign in here.
|
||||
</a>
|
||||
@@ -173,7 +173,7 @@ export function RegisterForm() {
|
||||
disabled={loading || !accepted}
|
||||
className={`w-full rounded py-2 text-white transition ${
|
||||
accepted
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
? "bg-brand-600 hover:bg-brand-700"
|
||||
: "bg-gray-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BRAND_600 } from "@/lib/theme";
|
||||
|
||||
export type TrendDatum = { label: string; value: number };
|
||||
|
||||
// Dependency-free single-series area/line chart, following the same
|
||||
// hand-rolled-SVG approach as reports/charts/HorizontalBarChart.tsx. A
|
||||
// single continuous series over time calls for a line/area (dataviz skill
|
||||
// form heuristic), so this uses one brand-consistent color rather than the
|
||||
// bar chart's multi-category palette.
|
||||
const WIDTH = 600;
|
||||
const HEIGHT = 220;
|
||||
const PADDING_LEFT = 48; // room for y-axis value labels
|
||||
const PADDING_RIGHT = 8;
|
||||
const PADDING_TOP = 12;
|
||||
const PADDING_BOTTOM = 24;
|
||||
const GRIDLINES = 4;
|
||||
|
||||
const defaultAxisFormatter = (v: number) => new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v);
|
||||
|
||||
export function AreaTrendChart({
|
||||
data,
|
||||
valueFormatter,
|
||||
axisFormatter,
|
||||
}: {
|
||||
data: TrendDatum[];
|
||||
/** Formats the headline "Latest" value — full precision is fine here. */
|
||||
valueFormatter?: (v: number) => string;
|
||||
/** Formats the y-axis gridline labels — should stay compact (little horizontal room). Defaults to a compact number (e.g. "1.5k"). */
|
||||
axisFormatter?: (v: number) => string;
|
||||
}) {
|
||||
const fmt = valueFormatter || ((v: number) => String(v));
|
||||
const axisFmt = axisFormatter || defaultAxisFormatter;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="text-xs text-gray-400">No data to chart.</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(1, ...data.map(d => d.value));
|
||||
const min = Math.min(0, ...data.map(d => d.value));
|
||||
const range = max - min || 1;
|
||||
const plotWidth = WIDTH - PADDING_LEFT - PADDING_RIGHT;
|
||||
const plotHeight = HEIGHT - PADDING_TOP - PADDING_BOTTOM;
|
||||
const baseline = PADDING_TOP + plotHeight;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = data.length === 1 ? PADDING_LEFT + plotWidth / 2 : PADDING_LEFT + (i / (data.length - 1)) * plotWidth;
|
||||
const y = PADDING_TOP + plotHeight - ((d.value - min) / range) * plotHeight;
|
||||
return { x, y, value: d.value };
|
||||
});
|
||||
|
||||
const linePath = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(" ");
|
||||
const areaPath = `${linePath} L ${points[points.length - 1].x} ${baseline} L ${points[0].x} ${baseline} Z`;
|
||||
|
||||
// Horizontal gridlines from 0 up to the max, evenly spaced, with a value label on each.
|
||||
const gridlines = Array.from({ length: GRIDLINES + 1 }, (_, i) => {
|
||||
const value = min + (range * i) / GRIDLINES;
|
||||
const y = PADDING_TOP + plotHeight - (i / GRIDLINES) * plotHeight;
|
||||
return { y, value };
|
||||
});
|
||||
|
||||
// Show at most ~6 x-axis labels so long series don't crowd the axis.
|
||||
const labelStep = Math.max(1, Math.ceil(data.length / 6));
|
||||
const visibleLabels = data.filter((_, i) => i % labelStep === 0 || i === data.length - 1);
|
||||
const latest = data[data.length - 1];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-gray-500">Latest</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{fmt(latest.value)}</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart">
|
||||
<defs>
|
||||
<linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={BRAND_600} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={BRAND_600} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gridlines.map((g, i) => (
|
||||
<g key={i}>
|
||||
<line x1={PADDING_LEFT} y1={g.y} x2={WIDTH - PADDING_RIGHT} y2={g.y} stroke="#F3F4F6" strokeWidth={1} />
|
||||
<text x={PADDING_LEFT - 6} y={g.y} textAnchor="end" dominantBaseline="middle" className="fill-gray-400" fontSize={10}>
|
||||
{axisFmt(g.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={areaPath} fill="url(#areaTrendFill)" stroke="none" />
|
||||
<path d={linePath} fill="none" stroke={BRAND_600} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={BRAND_600} strokeWidth={2} />
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400">
|
||||
{visibleLabels.map((d, i) => (
|
||||
<span key={d.label + i}>{d.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -19,12 +20,21 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
|
||||
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md hover:border-brand-200 transition">
|
||||
<a href={`/events/${event.id}`} className="block">
|
||||
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
|
||||
{event.picture ? (
|
||||
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-48 w-full bg-brand-50 flex items-center justify-center">
|
||||
<Calendar className="w-10 h-10 text-brand-200" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold hover:underline">{event.title}</h3>
|
||||
<p className="text-sm text-gray-500">{dateRange}</p>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
|
||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||
{dateRange}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -32,7 +42,7 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`/events/${event.id}`}
|
||||
className="flex-1 text-center text-sm text-gray-700 border py-2 rounded hover:bg-gray-50"
|
||||
className="flex-1 text-center text-sm text-gray-700 border rounded-lg py-2 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
View details
|
||||
</a>
|
||||
@@ -45,21 +55,21 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
const closed = (deadline ? now >= deadline : false) || now >= end;
|
||||
if (notYetOpen) {
|
||||
return (
|
||||
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration not yet open">
|
||||
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration not yet open">
|
||||
Opens {goLive?.toLocaleString()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (closed) {
|
||||
return (
|
||||
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration closed">
|
||||
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 rounded-lg py-2 cursor-not-allowed" title="Registration closed">
|
||||
Registration closed
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (event.isSoldOut) {
|
||||
return (
|
||||
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 py-2 rounded cursor-not-allowed">
|
||||
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg py-2 cursor-not-allowed">
|
||||
Sold Out
|
||||
</button>
|
||||
);
|
||||
@@ -67,7 +77,7 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
return (
|
||||
<a
|
||||
href={`/register/${event.id}`}
|
||||
className="flex-1 text-center text-sm text-white bg-blue-600 py-2 rounded hover:bg-blue-700"
|
||||
className="flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Register{event.price > 0 ? ` - R${event.price.toFixed(2)}` : ""}
|
||||
</a>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { Button } from "@/components/shared/Button";
|
||||
|
||||
export function EventForm({ onCreated }: { onCreated?: () => void }) {
|
||||
const { token } = useAuth();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [price, setPrice] = useState<number>(0);
|
||||
const [picture, setPicture] = useState("");
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [requiresAuth, setRequiresAuth] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleImageFile(file: File) {
|
||||
setUploadingImage(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const data = await apiFetch<{ url: string }>("/api/uploads/event-image", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: fd,
|
||||
});
|
||||
setPicture(data.url);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Image upload failed");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
setImagePreview(null);
|
||||
setPicture("");
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!token) { setError("Please login"); return; }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch("/api/events", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { title, description, startDate, endDate, price, picture, isHidden, requiresAuth },
|
||||
});
|
||||
onCreated?.();
|
||||
setTitle(""); setDescription(""); setStartDate(""); setEndDate(""); setPrice(0); setPicture(""); setImagePreview(null); setIsHidden(false); setRequiresAuth(true);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create event");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const previewSrc = imagePreview || (picture ? resolveToApiOrigin(picture) : null);
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Title</label>
|
||||
<input className="w-full border rounded px-3 py-2" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Description</label>
|
||||
<textarea className="w-full border rounded px-3 py-2" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Start</label>
|
||||
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">End</label>
|
||||
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Price (R)</label>
|
||||
<input type="number" step="0.01" className="w-full border rounded px-3 py-2" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event image</label>
|
||||
{previewSrc && (
|
||||
<div className="relative inline-block mb-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewSrc} alt="Preview" className="h-32 object-cover rounded border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearImage}
|
||||
className="absolute top-1 right-1 bg-white text-red-500 hover:text-red-700 rounded px-1 text-xs shadow"
|
||||
>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className={`cursor-pointer px-3 py-2 text-sm rounded border bg-gray-50 hover:bg-gray-100 ${uploadingImage ? "opacity-50 pointer-events-none" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
disabled={uploadingImage}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageFile(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{uploadingImage ? "Uploading…" : "Upload image"}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<input
|
||||
className="flex-1 border rounded px-3 py-2 text-sm"
|
||||
placeholder="https://…"
|
||||
value={picture}
|
||||
onChange={e => { setPicture(e.target.value); setImagePreview(null); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Visibility & Access</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={isHidden} onChange={e => setIsHidden(e.target.checked)} />
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Hidden event</span> — not shown in the public listing; accessible by direct link only
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={!requiresAuth} onChange={e => setRequiresAuth(!e.target.checked)} />
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Allow unauthenticated registration</span> — guests can register without creating an account
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<Button type="submit" loading={loading}>Create Event</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { brandColor } from "@/lib/siteConfig";
|
||||
import { BRAND_600 } from "@/lib/theme";
|
||||
|
||||
export const BottomNav = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { settings } = useSiteSettings();
|
||||
const accentColor = settings.accent_color || brandColor;
|
||||
// Fixed brand color — unlike the navbar's org name, nothing here is
|
||||
// admin-configurable (see globals.css for the rule).
|
||||
const accentColor = BRAND_600;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -32,7 +32,7 @@ export const BottomNav = () => {
|
||||
const items: NavItem[] = [
|
||||
{ href: "/", label: "Home", icon: Home },
|
||||
{ href: "/events", label: "Events", icon: Calendar },
|
||||
{ href: "/#contact", label: "Contact", icon: Phone },
|
||||
{ href: "/contact", label: "Contact", icon: Phone },
|
||||
...(authLoading
|
||||
? []
|
||||
: user
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
@@ -18,10 +19,20 @@ type NavLink = {
|
||||
export const Navbar = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { settings, loading: settingsLoading } = useSiteSettings();
|
||||
const pathname = usePathname();
|
||||
// Admin-configurable — applied ONLY to the org name text below via inline
|
||||
// style. No other element in the navbar (or anywhere else) should read
|
||||
// this; everything else uses the fixed brand-* palette.
|
||||
const displayName = settings.org_name || appName;
|
||||
const displayColor = settings.accent_color || brandColor;
|
||||
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href.includes("#")) return false;
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname?.startsWith(href) ?? false;
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
@@ -30,7 +41,7 @@ export const Navbar = () => {
|
||||
const navLinks: NavLink[] = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/events", label: "Events" },
|
||||
{ href: "/#contact", label: "Contact" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
const authLinks: NavLink[] = user
|
||||
@@ -78,7 +89,12 @@ export const Navbar = () => {
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
className={
|
||||
"text-sm pb-0.5 border-b-2 transition-colors " +
|
||||
(isActive(link.href)
|
||||
? "text-brand-600 border-brand-600 font-medium"
|
||||
: "text-gray-700 border-transparent hover:text-brand-600")
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@@ -92,7 +108,7 @@ export const Navbar = () => {
|
||||
<button
|
||||
key={link.label}
|
||||
onClick={link.onClick}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
className="text-sm pb-0.5 border-b-2 border-transparent text-gray-700 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
@@ -100,7 +116,12 @@ export const Navbar = () => {
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
className={
|
||||
"text-sm pb-0.5 border-b-2 transition-colors " +
|
||||
(isActive(link.href)
|
||||
? "text-brand-600 border-brand-600 font-medium"
|
||||
: "text-gray-700 border-transparent hover:text-brand-600")
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
|
||||
@@ -1,106 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, type LucideIcon } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { RoleBadge } from "@/components/shared/RoleBadge";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
|
||||
import { UserAvatar } from "@/components/shared/UserAvatar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
|
||||
const navLinks: { href: string; label: string; roles?: string[] }[] = [
|
||||
{ href: "/dashboard/user", label: "My Events", roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/profile", label: "Profile & Security", roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", roles: ["supervisor"] },
|
||||
{ href: "/dashboard/admin", label: "Admin", roles: ["admin"] },
|
||||
{ href: "/dashboard/admin/settings", label: "Site Settings", roles: ["admin"] }
|
||||
const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[] }[] = [
|
||||
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
|
||||
{ href: "/dashboard/admin", label: "Admin", icon: ShieldCheck, roles: ["admin"] },
|
||||
{ href: "/dashboard/admin/settings", label: "Site Settings", icon: Globe, roles: ["admin"] },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
function SidebarNavContent({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const role = user?.role || "user";
|
||||
const role = (user?.role || "user") as Role;
|
||||
const pathname = usePathname();
|
||||
const links = navLinks.filter(l => !l.roles || l.roles.includes(role));
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 border-r bg-white p-4">
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold">Dashboard</div>
|
||||
{user && (
|
||||
<div className="mt-1 text-xs text-gray-600 flex items-center space-x-2">
|
||||
<span>{user.name}</span>
|
||||
<RoleBadge role={role as any} />
|
||||
<div className="flex flex-col h-full">
|
||||
{user && (
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<UserAvatar name={user.name} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 truncate">{user.name}</div>
|
||||
<RoleBadge role={role} className="mt-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<nav className="space-y-1">
|
||||
{navLinks
|
||||
.filter((l) => !l.roles || l.roles.includes(role))
|
||||
.map((l) => (
|
||||
<Link key={l.href} className="block rounded px-3 py-2 text-sm hover:bg-gray-50" href={l.href}>
|
||||
{links.map(l => {
|
||||
const Icon = l.icon;
|
||||
const active = pathname === l.href;
|
||||
return (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={onNavigate}
|
||||
className={
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors " +
|
||||
(active ? "bg-brand-50 text-brand-700 font-medium" : "text-gray-600 hover:bg-gray-50")
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Desktop sidebar — fixed/pinned, does not scroll with page content. */
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden md:flex md:flex-col w-64 shrink-0 h-full overflow-y-auto border-r bg-white p-4">
|
||||
<SidebarNavContent />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile equivalent — a slim top strip with a hamburger that opens the same nav in a drawer. */
|
||||
export function MobileSidebar() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const role = user?.role || "user";
|
||||
const pathname = usePathname();
|
||||
|
||||
const options = navLinks.filter((l) => !l.roles || l.roles.includes(role));
|
||||
|
||||
// Determine selected value based on current path or default by role
|
||||
// Use longest matching href to handle nested paths (e.g. /dashboard/user/profile over /dashboard/user)
|
||||
const selectedFromPath = options
|
||||
.filter((o) => pathname?.startsWith(o.href))
|
||||
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
|
||||
const roleDefault: Record<string, string> = {
|
||||
admin: "/dashboard/admin",
|
||||
supervisor: "/dashboard/supervisor",
|
||||
staff: "/dashboard/staff",
|
||||
user: "/dashboard/user",
|
||||
};
|
||||
const selectedValue = selectedFromPath || roleDefault[role] || options[0]?.href || "";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value && value !== selectedValue) router.push(value);
|
||||
};
|
||||
const role = (user?.role || "user") as Role;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Dashboard</div>
|
||||
{user && (
|
||||
<div className="mt-0.5 text-xs text-gray-600 flex items-center space-x-2">
|
||||
<span>{user.name}</span>
|
||||
<RoleBadge role={role as any} />
|
||||
</div>
|
||||
)}
|
||||
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm flex items-center justify-between shrink-0">
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar name={user.name} className="w-8 h-8 text-xs" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 truncate">{user.name}</div>
|
||||
<RoleBadge role={role} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="mobile-dashboard-nav">Navigate dashboard</label>
|
||||
<select
|
||||
id="mobile-dashboard-nav"
|
||||
className="mt-3 w-full rounded-md border-gray-300 text-sm py-2 px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{/* Keep placeholder hidden if a value is selected */}
|
||||
{!selectedValue && (
|
||||
<option value="" disabled>
|
||||
Select section...
|
||||
</option>
|
||||
)}
|
||||
{options.map((l) => (
|
||||
<option key={l.href} value={l.href}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button type="button" aria-label="Open navigation" className="p-2 rounded-md hover:bg-gray-100 shrink-0">
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-72 p-4">
|
||||
<SidebarNavContent onNavigate={() => setOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user