Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6759e9c2d3 | ||
|
|
8799eec717 | ||
|
|
56e0c8a31f | ||
|
|
4bcb07f9d1 | ||
|
|
a0a3dc2416 | ||
|
|
51639322e2 | ||
|
|
1f186d6d2a | ||
|
|
55e7ca9546 | ||
|
|
8f69e58aa2 | ||
|
|
1b2f2a7c79 | ||
|
|
7906248c67 | ||
|
|
22ffbf201e | ||
|
|
be499f0d66 | ||
|
|
75b2f8ccc5 | ||
|
|
4f003f1628 | ||
|
|
b3ff2b9c5e | ||
|
|
0833888eaa | ||
|
|
b626404640 | ||
|
|
0f2b5afa74 | ||
|
|
8e6cb542d9 | ||
|
|
d74fec3a5c | ||
|
|
4bee1b24f7 | ||
|
|
70605923bf | ||
|
|
424141f216 |
@@ -34,3 +34,7 @@ Thumbs.db
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
backend/data/banner.json
|
||||
+103
@@ -7,6 +7,109 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.4] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The homepage "Join Us" button sent logged-in users to the registration page instead of somewhere useful. It now shows "My Dashboard" and links to their dashboard when a user is already logged in.
|
||||
|
||||
## [1.5.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
|
||||
|
||||
## [1.5.2] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stat card labels on the Admin and Supervisor dashboards (e.g. "Registrations (past month)") were getting cut off mid-word on mobile, where a cramped 2-column grid left too little room for the text. The mobile layout now shows one card per row, and labels wrap onto two lines instead of truncating.
|
||||
- The "Revenue trend" and "Top performing events" cards on the Admin and Supervisor dashboards could render outside the viewport on mobile: a wide events table (long titles, four columns) forced the containing grid column past the screen width instead of scrolling internally. Long event titles are now truncated in the table, and the page properly contains horizontal overflow.
|
||||
- The navbar Logout button sat about 2px lower than the other nav links (Home/Events/Contact/Dashboard) because it was missing the same underline-spacing classes those links use.
|
||||
|
||||
### Added
|
||||
|
||||
- Tab/mode switcher buttons on the Payments, At the door, Manual registration, Email attendees, WhatsApp attendees, and Cashup detail pages now show icons, matching the icons already used for the same tabs in each page's help guide.
|
||||
|
||||
## [1.5.1] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scheduling a WhatsApp message was silently sent as an email instead: the scheduled-job store never persisted the `channel` field, so the send worker always fell through to its email branch regardless of what was requested. Scheduled WhatsApp jobs now correctly send via WhatsApp.
|
||||
- Scheduled emails/WhatsApp messages showed no content when viewing or editing them in "Manage scheduled", even though the message existed in storage: the list endpoint never returned the message body (`html`/`text` for email, `message` for WhatsApp), and editing a scheduled WhatsApp message saved to the wrong payload field (`text` instead of `message`), so edits were silently lost. Both are now fixed, and the email list also shows a body preview like the WhatsApp one already did.
|
||||
|
||||
### Added
|
||||
|
||||
- Scheduled emails/WhatsApp messages are now automatically purged from storage 24 hours after they're sent, instead of accumulating indefinitely.
|
||||
- The "Manage scheduled" lists on the Email Attendees and WhatsApp Attendees pages now show who each scheduled job will be sent to, and each tab only shows jobs for its own channel (previously both tabs showed the same unfiltered list).
|
||||
|
||||
## [1.5.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color.
|
||||
- New help guides for account pages (login/register/forgot-password) and My Payments/Donate, replacing the generic fallback.
|
||||
- Site redesign, deep pass: beyond the phase 3 color sweep, the home page, events list, and event detail page were rebuilt with real layout upgrades (hero section, icon-chip headers, image placeholders, a sticky two-column tickets layout on event detail). Every Admin/Supervisor/Staff/User tool page now has an icon-chip header consistent with the dashboards, and the smaller public/auth pages (Login, Register, Forgot Password, Activate Account, Reset Password, Site Banner, Registration Success, Payment Success/Failure/Cancel, the standalone Attendee Forms page) were restyled to match, including wrapping the previously bare Attendee Forms page in the site's Navbar/Footer for the first time.
|
||||
- 17 new dedicated help guides were added (home, events, event detail, and every remaining Admin/Supervisor/Staff/User tool page), so the whole site now has page-specific help content instead of falling back to the generic guide.
|
||||
|
||||
- Site redesign, phase 1 (foundation): fixed indigo/purple design tokens wired into Tailwind and the previously-unstyled shadcn components (Button, Card, Badge, Table, Sheet, etc. were silently missing their CSS variables); a site-wide floating "Need help?" button, present on every page, showing a contextual guide with quick links (e.g. "Browse events", "View my tickets") — content is resolved per page via a new registry, with a general fallback (how to register / manage tickets) for any page without dedicated content yet.
|
||||
- Dashboard sidebar (My Events, Profile & Security, Admin, Site Settings) now also shows on `/dashboard/user/profile` and `/dashboard/admin/settings`, not just each role's root page, and is now fixed in place so it no longer scrolls with page content.
|
||||
- New shared components (`StatCard`, `QuickActionTile`, `AreaTrendChart`) scaffolded for the upcoming dashboard redesign.
|
||||
- Contact now has its own dedicated page (`/contact`) instead of being a scroll-to section on the home page — same org email/phone/address, restyled to match the new design.
|
||||
- The general (public) and My Events help guides now go into much more detail: the general guide explains guest vs. account-required registration with quick links to log in or create an account, and the My Events guide walks through registration status badges, attendee forms, early-bird pricing, editing/cancelling, bulk ticket actions, and the difference between paying a balance, payment history, and donations. The Admin, Supervisor, Staff, and Site Settings pages now have their own dedicated help guides too, instead of falling back to the general one.
|
||||
- Site redesign, phase 2 (dashboards): Admin and Supervisor dashboards now show a KPI row (active events, revenue, donations, registrations, tickets sold — each with a "vs last month" comparison), a revenue trend chart for the current month, and a top-performing-events table, backed by a new `GET /api/stats/overview` endpoint. Staff and My Events dashboards are visually reskinned onto the new design tokens only — no new financial data for Staff, by design.
|
||||
- Admin/Supervisor dashboard stat tiles now link somewhere useful: Revenue/Donations/Tickets sold jump straight into the matching Reports report, pre-filtered to this month (new `?report=&range=this_month` deep-linking support in Reports); Active events links to Manage events.
|
||||
- Reports revenue trend chart now shows gridlines, axis labels, and a marker dot per data point, so a sparse day-or-two of data still renders as a visible chart instead of an empty-looking box.
|
||||
- WhatsApp API management moved from its own page into a new "WhatsApp" tab on Site Settings (`/dashboard/admin/settings?tab=whatsapp`); the old `/dashboard/admin/whatsapp` route now just redirects there.
|
||||
- Site Settings restyled to match the rest of the redesign (icon tab bar, card layout, brand colors).
|
||||
- My Events dashboard now shows event thumbnails on registrations, upcoming events, and tickets, with upcoming events and tickets laid out side by side.
|
||||
- Profile & Security page restyled into a two-column layout with a new "Account activity" section showing recent logins and password changes (device + timestamp), backed by a new append-only `SecurityEvent` log and `GET /api/users/activity` endpoint.
|
||||
- Help guide modal now animates in instead of appearing abruptly, and got a general visual polish pass (rounded corners, active-tab styling, hover states).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Admin/Supervisor dashboard "Revenue" figures (today/week/month) were overcounting: they didn't exclude donation-application "legs" (the money was already counted once via the original donation) the way Reports and the admin payments-stats endpoint already did. Both endpoints now use the same exclusion.
|
||||
- Admin/Supervisor dashboard "Revenue" KPI, trend chart, and top-performing-events table were undercounting the other way — donations themselves weren't being counted as revenue at all, only tracked in the separate "Donations" figure. Donations now count toward revenue (donation-application legs are still excluded either way, since that money was already counted once via the original donation).
|
||||
- Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) used a calendar-month-to-date window, which under-counted for most of the month (e.g. only 3 days' worth of data on the 3rd). They now use a rolling trailing-month window instead — today back one calendar month, e.g. 9 May through 8 June if today is 8 June — compared against the equal-length month before that. The "View report" links from those tiles now deep-link into the same trailing-month range.
|
||||
- Admin dashboard was missing the "At the door" quick action that Supervisor already had (walk-in registration, payments, and ticket printing at the door) — added.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reports' "Reporting guide" is now powered by the new site-wide help system instead of a Reports-only modal — same content, opened from the floating help button instead of a sidebar button.
|
||||
- Navbar/bottom nav link colors are now fixed to the site's indigo brand color instead of the admin-configurable accent color; the accent color setting now only affects the org name text next to the logo.
|
||||
- "Contact" links across the nav and help content now point to `/contact` instead of `/#contact`.
|
||||
- Admin/Supervisor dashboard quick actions no longer list "Manage sections" or "Event tickets & printing" (redundant with the Events and At-the-door pages) or "Manage WhatsApp API" (moved into Site Settings). Staff keeps "Event tickets & printing" — their only other tool is ticket scanning, and they can't reach At-the-door.
|
||||
- Admin dashboard no longer shows ticket-scanning stats ("Recent scans") — kept on Supervisor/Staff, where it's actually actionable.
|
||||
- The old hand-rolled `Button` component (`components/shared/Button.tsx`) is gone — its one remaining caller now uses the standard `components/ui/button.tsx`.
|
||||
- The site-wide help guide no longer opens itself automatically on first visit to a page — it only opens when the help button is clicked. The now-pointless "Don't show this again" checkbox was removed along with the per-page dismissal tracking it drove.
|
||||
|
||||
### Removed
|
||||
|
||||
- Deleted dead code found while migrating the last `Button` usage: an unused `EventForm` component, a stale pre-redesign `Reports.tsx`, and a stray `ReportsV2_backup.tsx` — none were imported anywhere.
|
||||
|
||||
## [1.4.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Supervisor Payments page: the mode tabs (Payment/Refund/Donations/Reconcile/Payment Link) now wrap onto multiple lines on mobile instead of overflowing off-screen and becoming unreachable.
|
||||
- Reports: the payments report and its "by method" breakdown/chart now bucket into the same 4 categories used everywhere else (Cash, Card, EFT, Other) instead of showing raw method strings — Apple Pay, Google Pay, and Yoco checkout-portal payments now fold into "Card".
|
||||
- Cashup/Finance/Profit reports: refunds were being dropped entirely from the per-method breakdown (`paymentsByMethod`) instead of netting against the method they were refunded against, so e.g. a card refund silently vanished instead of reducing the "Card" total — the per-method figures now correctly sum back to total revenue.
|
||||
- Refund method now nets against the correct bucket everywhere a payment's method is normalized for display/filtering (My Payments page, reports) — `card-refund` etc. was falling through to "Other" instead of being recognized as a refund of its base method.
|
||||
- Supervisor Payments page: the Refund form's method dropdown now mirrors the actual payment methods (Cash/Card/EFT/Voucher refund) instead of offering an ambiguous generic "Refund" option that couldn't be attributed to any method bucket; it now auto-fills from the original payment's method when refunding a specific payment.
|
||||
- Donations: refunding a donation (fully or partially) was inflating its remaining/unallocated balance by the refunded amount instead of reducing it, since the refund's negative amount was subtracted straight into the balance (subtracting a negative adds). Could let staff over-allocate a donation that had actually shrunk. Fixed in the donation-assignment leg totals used by the assign-donation endpoint, the Cashup/Finance/Profit reports' unallocated-donations figure, the Donations and Master Orders reports' Used/Unused breakdown, and the "Assign donation" panel on the Supervisor Payments page.
|
||||
|
||||
### Added
|
||||
|
||||
- Supervisor Payments page: reconciling a Yoco transaction as a donation now lets staff optionally pick who the donation is from, instead of it always being attributed to whatever the checkout metadata (or the reconciling staff member) happened to resolve to.
|
||||
|
||||
## [1.4.1] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
|
||||
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
|
||||
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
|
||||
|
||||
## [1.4.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"message": "",
|
||||
"type": "info",
|
||||
"liveFrom": null,
|
||||
"liveTill": null
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"jobs": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.4",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SecurityEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"type" "SecurityEventType" NOT NULL,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,12 @@ enum EventCostType {
|
||||
per_item
|
||||
}
|
||||
|
||||
enum SecurityEventType {
|
||||
login
|
||||
password_changed
|
||||
password_reset
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@@ -78,6 +84,8 @@ model User {
|
||||
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||
|
||||
securityEvents SecurityEvent[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -291,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
|
||||
|
||||
@@ -57,6 +57,23 @@ function parseFreeformEmails(lines) {
|
||||
return recipients;
|
||||
}
|
||||
|
||||
// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI
|
||||
async function describeBroadcastRecipients({ userIds, emails }) {
|
||||
const parts = [];
|
||||
try {
|
||||
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
|
||||
if (ids.length) {
|
||||
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
|
||||
const names = users.map(u => u.name).filter(Boolean);
|
||||
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
|
||||
}
|
||||
if (Array.isArray(emails) && emails.length) {
|
||||
parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`);
|
||||
}
|
||||
} catch {}
|
||||
return parts.length ? parts.join('; ') : 'No recipients';
|
||||
}
|
||||
|
||||
// @desc Preview broadcast recipients and sample
|
||||
// @route POST /api/broadcasts/preview
|
||||
// @access Private/Supervisor or Admin
|
||||
@@ -191,11 +208,14 @@ const scheduleBroadcast = async (req, res) => {
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
broadcast: true,
|
||||
scheduledAt: when.toISOString(),
|
||||
createdById: req.user?.id || null,
|
||||
recipientSummary,
|
||||
payload,
|
||||
});
|
||||
|
||||
|
||||
@@ -1569,6 +1569,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI
|
||||
function describeAttendeeFilter(filter) {
|
||||
if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) {
|
||||
return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' };
|
||||
return labels[filter?.status] || 'All attendees';
|
||||
}
|
||||
|
||||
// @desc Schedule email to attendees at a specific date/time
|
||||
// @route POST /api/events/:id/email-attendees/schedule
|
||||
// @access Private/Supervisor or Admin
|
||||
@@ -1600,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
eventId,
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
@@ -1637,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
channel: 'whatsapp',
|
||||
createdById: req.user?.id || null,
|
||||
scheduledAt: when.toISOString(),
|
||||
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
|
||||
payload,
|
||||
});
|
||||
|
||||
|
||||
@@ -365,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';
|
||||
@@ -394,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' } }))
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,11 +612,14 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
|
||||
// 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.
|
||||
// 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 + leg.amount, 0);
|
||||
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
||||
const remainingDonation = payment.amount - alreadyUsed;
|
||||
|
||||
if (remainingDonation <= 0.000001) {
|
||||
|
||||
@@ -6,11 +6,14 @@ function toClient(job) {
|
||||
const subject = job?.payload?.subject || '';
|
||||
const html = job?.payload?.html || '';
|
||||
const text = job?.payload?.text || '';
|
||||
const message = job?.payload?.message || '';
|
||||
return {
|
||||
id: job.id,
|
||||
kind,
|
||||
eventId: job.eventId || null,
|
||||
broadcast: !!job.broadcast,
|
||||
channel: job.channel || 'email',
|
||||
recipient: job.recipientSummary || null,
|
||||
scheduledAt: job.scheduledAt,
|
||||
createdAt: job.createdAt,
|
||||
status: job.status,
|
||||
@@ -18,26 +21,21 @@ function toClient(job) {
|
||||
sentAt: job.sentAt || null,
|
||||
lastError: job.lastError || null,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
message,
|
||||
hasHtml: !!html,
|
||||
hasText: !!text,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/scheduled-emails
|
||||
// Returns jobs excluding emails sent more than a week ago
|
||||
// GET /api/scheduled-emails?channel=email|whatsapp
|
||||
// Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here
|
||||
const listScheduledEmails = async (req, res) => {
|
||||
try {
|
||||
const raw = listJobs();
|
||||
const now = new Date();
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const filtered = raw.filter(j => {
|
||||
if (j.status === 'sent' && j.sentAt) {
|
||||
const sentAt = new Date(j.sentAt).getTime();
|
||||
return (now.getTime() - sentAt) <= weekMs;
|
||||
}
|
||||
// Include queued, sending, error by default
|
||||
return true;
|
||||
})
|
||||
const { channel } = req.query || {};
|
||||
const raw = listJobs(channel ? { channel: String(channel) } : {});
|
||||
const filtered = raw
|
||||
// Provide most-relevant first: queued -> sending -> error -> recent sent
|
||||
.sort((a, b) => {
|
||||
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
|
||||
@@ -56,7 +54,7 @@ const listScheduledEmails = async (req, res) => {
|
||||
};
|
||||
|
||||
// PATCH /api/scheduled-emails/:id
|
||||
// Allows editing scheduledAt, subject, html/text on queued jobs only
|
||||
// Allows editing scheduledAt and message content (subject/html/text for email jobs, message for WhatsApp jobs) on queued jobs only
|
||||
const updateScheduledEmail = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -64,7 +62,7 @@ const updateScheduledEmail = async (req, res) => {
|
||||
if (!job) return res.status(404).json({ message: 'Job not found' });
|
||||
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
|
||||
|
||||
const { scheduledAt, subject, html, text } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, message } = req.body || {};
|
||||
|
||||
const patch = {};
|
||||
if (scheduledAt) {
|
||||
@@ -72,14 +70,12 @@ const updateScheduledEmail = async (req, res) => {
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
patch.scheduledAt = when.toISOString();
|
||||
}
|
||||
if (subject != null || html != null || text != null) {
|
||||
if (subject != null || html != null || text != null || message != null) {
|
||||
const payload = { ...(job.payload || {}) };
|
||||
if (subject != null) payload.subject = subject;
|
||||
if (html != null || text != null) {
|
||||
// If html provided explicitly, set html; if text provided, set text
|
||||
if (html != null) payload.html = html;
|
||||
if (text != null) payload.text = text;
|
||||
}
|
||||
if (message != null) payload.message = message;
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -399,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({
|
||||
@@ -744,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) });
|
||||
@@ -929,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,
|
||||
@@ -946,4 +963,5 @@ module.exports = {
|
||||
revokeMySession,
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
};
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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,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('/')
|
||||
|
||||
@@ -103,18 +103,23 @@ async function computeEventFinancials(eventId) {
|
||||
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
||||
}
|
||||
|
||||
// Real inflows only — 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).
|
||||
const nonRefundPayments = payments.filter(p => p.amount > 0 && !isDonationLeg(p));
|
||||
// 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".
|
||||
// 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) + p.amount);
|
||||
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
|
||||
}
|
||||
}
|
||||
const donationPayments = payments.filter(p => p.isDonation);
|
||||
@@ -123,7 +128,7 @@ async function computeEventFinancials(eventId) {
|
||||
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 + (isDonationLeg(p) ? 0 : p.amount), 0);
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.4",
|
||||
"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"];
|
||||
@@ -57,12 +58,17 @@ export default function EventCashupPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
</div>
|
||||
</div>
|
||||
<span className={"text-xs px-2 py-1 rounded-full font-medium " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||
{isClosed ? "Closed" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,9 +76,9 @@ export default function EventCashupPage() {
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||
|
||||
<div className="flex gap-2 border-b">
|
||||
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
|
||||
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
|
||||
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
|
||||
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}><DollarSign className="w-4 h-4" />Costs</button>
|
||||
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}><ClipboardCheck className="w-4 h-4" />Reconciliation</button>
|
||||
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}><FileBarChart className="w-4 h-4" />Report</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
||||
@@ -169,7 +175,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Event costs</div>
|
||||
{!isClosed && editingId === null && (
|
||||
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
|
||||
<button className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={startNew}>+ Add cost</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -199,7 +205,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
|
||||
{!isClosed && (
|
||||
<td className="py-1.5 text-right whitespace-nowrap">
|
||||
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
|
||||
<button className="text-xs text-brand-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
|
||||
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
|
||||
</td>
|
||||
)}
|
||||
@@ -264,7 +270,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} disabled={saving}>Cancel</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -378,7 +384,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
<td className="py-2.5 text-right pl-2">
|
||||
{r.userId && (
|
||||
<button
|
||||
className="text-xs text-indigo-600 hover:underline whitespace-nowrap"
|
||||
className="text-xs text-brand-600 hover:underline whitespace-nowrap"
|
||||
onClick={() => setEditingUserId(editingUserId === r.userId ? null : r.userId)}
|
||||
>
|
||||
{r.cash.actual != null ? "Edit count" : "Enter count"}
|
||||
@@ -495,7 +501,7 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="text-xs px-3 py-1.5 rounded border" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -803,7 +809,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
<div className="flex flex-wrap gap-2 justify-end pt-1">
|
||||
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
|
||||
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
|
||||
export default function CashupLandingPage() {
|
||||
const { token } = useAuth();
|
||||
@@ -46,14 +47,19 @@ export default function CashupLandingPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>Back</button>
|
||||
</div>
|
||||
@@ -87,7 +93,7 @@ export default function CashupLandingPage() {
|
||||
return (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
|
||||
className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
|
||||
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -101,7 +107,7 @@ export default function CashupLandingPage() {
|
||||
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-indigo-600 shrink-0">Manage →</span>
|
||||
<span className="text-xs text-brand-600 shrink-0">Manage →</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
<StatCardRow>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
|
||||
</ul>
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||
{overview && overview.trend.length > 0 ? (
|
||||
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
|
||||
{overview && overview.topEvents.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead className="text-right">Registrations</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Tickets sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.topEvents.map(e => (
|
||||
<TableRow key={e.eventId}>
|
||||
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Payments</h2>
|
||||
<h2 className="text-lg font-semibold">Payments overview</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<div className="flex gap-1 mb-3 bg-gray-100 rounded-lg p-1">
|
||||
{(["today", "week", "month"] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setPaymentsTab(t)}
|
||||
className={"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors " + (paymentsTab === t ? "bg-white text-brand-700 shadow-sm" : "text-gray-500 hover:text-gray-700")}
|
||||
>
|
||||
{t === "today" ? "Today" : t === "week" ? "Past week" : "Past month"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{paymentStats ? (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Week</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Past Month</div>
|
||||
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded p-3 bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Active events</div>
|
||||
<div className="text-lg font-semibold">{activeEventsCount}</div>
|
||||
</div>
|
||||
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Revenue today</div>
|
||||
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Donations today</div>
|
||||
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mt-6">
|
||||
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -185,9 +186,17 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Manage Registrations</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ClipboardList className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
|
||||
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
@@ -304,7 +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'}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "organisation", label: "Organisation" },
|
||||
{ id: "branding", label: "Branding" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "legal", label: "Legal" },
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
{ id: "branding", label: "Branding", icon: Palette },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
function Field({
|
||||
label, hint, required, children,
|
||||
@@ -56,7 +58,7 @@ function SaveBar({
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
@@ -65,11 +67,21 @@ function SaveBar({
|
||||
}
|
||||
|
||||
export default function SiteSettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-sm text-gray-500">Loading settings…</div>}>
|
||||
<SiteSettingsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteSettingsPageInner() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>("organisation");
|
||||
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
|
||||
// Per-tab save state
|
||||
@@ -269,15 +281,15 @@ export default function SiteSettingsPage() {
|
||||
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6">
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Site Settings</h1>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Site Settings</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200"
|
||||
onClick={() => router.push("/dashboard/admin")}
|
||||
>
|
||||
Back
|
||||
@@ -285,26 +297,32 @@ export default function SiteSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-6">
|
||||
{TABS.map((tab) => (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-4">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => switchTab(tab.id)}
|
||||
className={`px-3 py-1.5 text-sm rounded border transition-colors ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white text-gray-800 border-gray-200 hover:bg-gray-50"
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
{/* ── Organisation ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "organisation" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
|
||||
<Field label="Organisation name" required>
|
||||
<input className={inputCls} placeholder="Hope Family Church"
|
||||
value={orgName} onChange={e => setOrgName(e.target.value)} />
|
||||
@@ -338,7 +356,8 @@ export default function SiteSettingsPage() {
|
||||
{/* ── Branding ─────────────────────────────────────────────────────── */}
|
||||
{activeTab === "branding" && (
|
||||
<div className="space-y-5">
|
||||
<Field label="Accent / brand colour">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
|
||||
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
@@ -377,6 +396,7 @@ export default function SiteSettingsPage() {
|
||||
{/* ── Notifications ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "notifications" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
|
||||
<Field
|
||||
label="Registration notification emails"
|
||||
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
|
||||
@@ -391,6 +411,7 @@ export default function SiteSettingsPage() {
|
||||
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
|
||||
{activeTab === "email" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Email</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Outgoing email for tickets, payment confirmations, and account notifications.
|
||||
Leave blank to use server environment variables. The password is stored encrypted.
|
||||
@@ -470,6 +491,7 @@ export default function SiteSettingsPage() {
|
||||
{/* ── Legal ─────────────────────────────────────────────────────────── */}
|
||||
{activeTab === "legal" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Legal</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
These values populate the Terms of Use and Privacy Policy pages automatically.
|
||||
</p>
|
||||
@@ -505,6 +527,546 @@ export default function SiteSettingsPage() {
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── WhatsApp tab (migrated from the old standalone /dashboard/admin/whatsapp page) ───
|
||||
|
||||
type WAStatus =
|
||||
| "WORKING"
|
||||
| "CONNECTED"
|
||||
| "SCAN_QR_CODE"
|
||||
| "STARTING"
|
||||
| "FAILED"
|
||||
| "STOPPED"
|
||||
| string;
|
||||
|
||||
interface ConfigResponse {
|
||||
tokenMasked: string;
|
||||
instanceId: string;
|
||||
hasToken: boolean;
|
||||
hasInstance: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
status: WAStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
WORKING: "bg-green-100 text-green-800 border-green-300",
|
||||
CONNECTED: "bg-green-100 text-green-800 border-green-300",
|
||||
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
|
||||
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
|
||||
FAILED: "bg-red-100 text-red-800 border-red-300",
|
||||
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
WORKING: "🟢",
|
||||
CONNECTED: "🟢",
|
||||
SCAN_QR_CODE: "📷",
|
||||
STARTING: "🔄",
|
||||
FAILED: "🔴",
|
||||
STOPPED: "⚫",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
|
||||
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="animate-spin h-4 w-4 text-brand-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WAAlert({ type, children }: { type: "ok" | "err" | "info"; children: React.ReactNode }) {
|
||||
const cls =
|
||||
type === "ok" ? "bg-green-50 text-green-800 border-green-200"
|
||||
: type === "err" ? "bg-red-50 text-red-800 border-red-200"
|
||||
: "bg-blue-50 text-blue-800 border-blue-200";
|
||||
return <div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>;
|
||||
}
|
||||
|
||||
function StepIndicator({ step }: { step: number }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Access Token" },
|
||||
{ n: 2, label: "Session Instance" },
|
||||
{ n: 3, label: "Connected" },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-0">
|
||||
{steps.map((s, i) => {
|
||||
const done = step > s.n;
|
||||
const current = step === s.n;
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
|
||||
done ? "bg-green-500 border-green-500 text-white"
|
||||
: current ? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-300 text-gray-400"
|
||||
}`}>
|
||||
{done ? "✓" : s.n}
|
||||
</div>
|
||||
<span className={`text-xs mt-1 font-medium ${done || current ? "text-gray-700" : "text-gray-400"}`}>{s.label}</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${done ? "bg-green-400" : "bg-gray-200"}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
|
||||
|
||||
function ActionButton({
|
||||
label, busyLabel, isBusy, disabled, color, onClick,
|
||||
}: {
|
||||
label: string; busyLabel: string; isBusy: boolean; disabled: boolean; color: ButtonColor; onClick: () => void;
|
||||
}) {
|
||||
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
|
||||
const colors: Record<ButtonColor, string> = {
|
||||
green: "bg-green-600 text-white hover:bg-green-700",
|
||||
amber: "bg-amber-500 text-white hover:bg-amber-600",
|
||||
blue: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
|
||||
};
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
|
||||
{isBusy && <Spinner />}
|
||||
{isBusy ? busyLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** `active` gates all polling — only run status/QR polling while this tab is actually visible. */
|
||||
function WhatsAppTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
|
||||
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
|
||||
const [cfgLoading, setCfgLoading] = useState(true);
|
||||
|
||||
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
|
||||
|
||||
const [inputToken, setInputToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
|
||||
const [inputInstanceId, setInputInstanceId] = useState("");
|
||||
const [savingInstance, setSavingInstance] = useState(false);
|
||||
|
||||
const [status, setStatus] = useState<WAStatus | null>(null);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
const [qrSrc, setQrSrc] = useState<string | null>(null);
|
||||
const [pairingPhone, setPairingPhone] = useState("");
|
||||
|
||||
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
|
||||
setCfg(res);
|
||||
} catch {
|
||||
// network error — leave cfg null, tab shows loading state
|
||||
} finally {
|
||||
setCfgLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (active) fetchConfig(); }, [fetchConfig, active]);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!token || step !== 3) return;
|
||||
try {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
}
|
||||
}, [token, step, fetchConfig]);
|
||||
|
||||
useEffect(() => { if (active && step === 3) fetchStatus(); }, [step, fetchStatus, active]);
|
||||
|
||||
// Auto-poll status when not stable — only while this tab is active.
|
||||
useEffect(() => {
|
||||
if (!active || step !== 3 || status === null) return;
|
||||
if (ACTIVE_STATUSES.has(status)) return;
|
||||
const id = setInterval(fetchStatus, 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, [active, step, status, fetchStatus]);
|
||||
|
||||
const fetchQr = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
|
||||
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
|
||||
} catch {
|
||||
setQrSrc(null);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && status === "SCAN_QR_CODE") { fetchQr(); }
|
||||
else { setQrSrc(null); }
|
||||
}, [status, fetchQr, active]);
|
||||
|
||||
// Auto-refresh QR every 20s while waiting — only while this tab is active.
|
||||
useEffect(() => {
|
||||
if (!active || status !== "SCAN_QR_CODE") return;
|
||||
const id = setInterval(fetchQr, 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [active, status, fetchQr]);
|
||||
|
||||
const doAction = async (action: string, body?: object) => {
|
||||
if (!token) return;
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||
await fetchStatus();
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
let msg = e?.message || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
setActionMsg({ type: "err", text: msg });
|
||||
}
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveToken = async () => {
|
||||
if (!inputToken.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
|
||||
setInputToken("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveInstanceId = async () => {
|
||||
if (!inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
|
||||
return;
|
||||
}
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createInstance = async () => {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPairingCode = async () => {
|
||||
if (!pairingPhone.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter your phone number first." });
|
||||
return;
|
||||
}
|
||||
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
|
||||
};
|
||||
|
||||
const resetToken = async () => {
|
||||
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "_clear_", instanceId: "" } });
|
||||
} catch {}
|
||||
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
|
||||
};
|
||||
|
||||
if (cfgLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner /> Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">WhatsApp integration</h2>
|
||||
<p className="text-sm text-gray-500">Powered by WAWP — used to send tickets and notifications via WhatsApp.</p>
|
||||
</div>
|
||||
|
||||
<StepIndicator step={step} />
|
||||
|
||||
{actionMsg && <WAAlert type={actionMsg.type}>{actionMsg.text}</WAAlert>}
|
||||
|
||||
{step === 1 && (
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
||||
<h3 className="text-base font-semibold">Step 1 — Enter your WAWP Access Token</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Your access token is found in your WAWP account dashboard at{" "}
|
||||
<a href="https://app.wawp.net" target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">app.wawp.net</a>.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-700">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputToken}
|
||||
onChange={e => setInputToken(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveToken()}
|
||||
placeholder="Paste your WAWP access token"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={saveToken} disabled={savingToken} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Token & Continue"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">Step 2 — Set Up Session Instance</h3>
|
||||
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">Token: {cfg?.tokenMasked}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">You need a WAWP session instance. Either create a brand-new one, or enter an existing Instance ID.</p>
|
||||
|
||||
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
|
||||
<button onClick={() => setInstanceMode("create")} className={`flex-1 px-4 py-2.5 transition-colors ${instanceMode === "create" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`}>
|
||||
Create new instance
|
||||
</button>
|
||||
<button onClick={() => setInstanceMode("enter")} className={`flex-1 px-4 py-2.5 border-l transition-colors ${instanceMode === "enter" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`}>
|
||||
Enter existing ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{instanceMode === "create" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-600">Click below to create a new WAWP session. The Instance ID will be saved automatically.</p>
|
||||
<button onClick={createInstance} disabled={savingInstance} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Creating…" : "Create Instance"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instanceMode === "enter" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instance ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputInstanceId}
|
||||
onChange={e => setInputInstanceId(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
|
||||
placeholder="e.g. BF14B761C364"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={saveInstanceId} disabled={savingInstance} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Saving…" : "Save & Continue"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={resetToken} className="text-xs text-gray-400 hover:text-red-500 hover:underline">← Change access token</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">Session Status</h3>
|
||||
<button onClick={fetchStatus} className="text-xs text-brand-600 hover:underline">Refresh</button>
|
||||
</div>
|
||||
|
||||
{status === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500"><Spinner /> Fetching status…</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"}`}>{status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
|
||||
|
||||
{status === "FAILED" && (
|
||||
<WAAlert type="err">The session has failed. The system will attempt to auto-restart. You can also restart manually below.</WAAlert>
|
||||
)}
|
||||
|
||||
{status && POLLING_STATUSES.has(status) && (
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1"><Spinner /> Auto-refreshing every 5 seconds…</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
|
||||
<span>Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span></span>
|
||||
<span>Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">Scan QR Code</h3>
|
||||
<button onClick={fetchQr} className="text-xs text-brand-600 hover:underline">Refresh QR</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Open WhatsApp → Linked Devices → Link a Device, then scan the code below.</p>
|
||||
{qrSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={qrSrc} alt="WhatsApp QR Code" className="w-56 h-56 border rounded-lg" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400"><Spinner /> Loading QR…</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">QR codes expire after ~20 seconds — click Refresh QR if it stops working.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
||||
<h3 className="text-base font-semibold">Link by Phone Number Instead</h3>
|
||||
<p className="text-sm text-gray-600">Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing code on your phone.</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="tel"
|
||||
value={pairingPhone}
|
||||
onChange={e => setPairingPhone(e.target.value)}
|
||||
placeholder="082 123 4567"
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<button onClick={requestPairingCode} disabled={busy === "request-code"} className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy === "request-code" && <Spinner />}
|
||||
{busy === "request-code" ? "Sending…" : "Send code"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
||||
<h3 className="text-base font-semibold">Session Controls</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton label="Start" busyLabel="Starting…" isBusy={busy === "start"} disabled={!!busy} color="green" onClick={() => doAction("start")} />
|
||||
<ActionButton label="Restart" busyLabel="Restarting…" isBusy={busy === "restart"} disabled={!!busy} color="amber" onClick={() => doAction("restart")} />
|
||||
<ActionButton label="Logout" busyLabel="Logging out…" isBusy={busy === "logout"} disabled={!!busy} color="red-outline" onClick={() => {
|
||||
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
|
||||
doAction("logout");
|
||||
}} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
||||
<h3 className="text-base font-semibold">Instance Management</h3>
|
||||
<p className="text-sm text-gray-600">Create a brand-new instance or permanently delete the current one. Deleting will require you to set up a new instance.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton label="Create New Instance" busyLabel="Creating…" isBusy={busy === "create-instance"} disabled={!!busy} color="blue" onClick={() => doAction("create-instance")} />
|
||||
<ActionButton label="Delete Instance" busyLabel="Deleting…" isBusy={busy === "delete-instance"} disabled={!!busy} color="red-outline" onClick={() => {
|
||||
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
|
||||
doAction("delete-instance");
|
||||
}} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="border rounded-xl bg-gray-50">
|
||||
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
|
||||
<span>Update Credentials</span>
|
||||
<span className="text-gray-400 text-xs">expand ▾</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-5 space-y-3 border-t pt-4">
|
||||
<p className="text-sm text-gray-600">Change your WAWP access token or Instance ID. Leave a field blank to keep the current value.</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">New Access Token</label>
|
||||
<input type="password" value={inputToken} onChange={e => setInputToken(e.target.value)} placeholder="Leave blank to keep current token" className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">New Instance ID</label>
|
||||
<input type="text" value={inputInstanceId} onChange={e => setInputInstanceId(e.target.value)} placeholder="Leave blank to keep current instance" className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500" />
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!inputToken.trim() && !inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter at least one field to update." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: inputToken.trim() || undefined, instanceId: inputInstanceId.trim() || undefined },
|
||||
});
|
||||
setActionMsg({ type: "ok", text: "Credentials updated." });
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
}}
|
||||
disabled={savingToken}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
WhatsApp notifications powered by{" "}
|
||||
<a href="https://wawp.net" target="_blank" rel="noopener noreferrer" className="hover:underline">WAWP</a>
|
||||
. Session auto-recovers on failure; admin alert sent if recovery fails.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -206,11 +208,19 @@ export default function AdminUsersPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">User Management</h1>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UsersIcon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
|
||||
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
|
||||
{createOpen ? "Close" : "Create user"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -249,7 +259,7 @@ export default function AdminUsersPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
|
||||
<button disabled={creating} className="px-3 py-2 rounded bg-brand-600 text-white disabled:opacity-50" type="submit">
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -298,8 +308,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mb-2">
|
||||
{total} user{total !== 1 ? "s" : ""} total
|
||||
{total > 0 && ` — page ${page} of ${totalPages}`}
|
||||
{total > 0 && `Page ${page} of ${totalPages}`}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
@@ -325,7 +334,7 @@ export default function AdminUsersPage() {
|
||||
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="capitalize">{u.role}</span>
|
||||
<RoleBadge role={u.role as RoleBadgeRole} />
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span>{u.phoneNumber || ""}</span>
|
||||
@@ -334,7 +343,10 @@ export default function AdminUsersPage() {
|
||||
<span className="capitalize">{u.notificationPreference || "email"}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${u.isActive ? "bg-green-50 text-green-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${u.isActive ? "bg-green-500" : "bg-gray-400"}`} />
|
||||
{u.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
@@ -384,7 +396,7 @@ export default function AdminUsersPage() {
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
||||
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
||||
disabled={fetching}
|
||||
onClick={() => goToPage(p as number)}
|
||||
>
|
||||
@@ -461,7 +473,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,831 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WAStatus =
|
||||
| "WORKING"
|
||||
| "CONNECTED"
|
||||
| "SCAN_QR_CODE"
|
||||
| "STARTING"
|
||||
| "FAILED"
|
||||
| "STOPPED"
|
||||
| string;
|
||||
|
||||
interface ConfigResponse {
|
||||
tokenMasked: string;
|
||||
instanceId: string;
|
||||
hasToken: boolean;
|
||||
hasInstance: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
status: WAStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
WORKING: "bg-green-100 text-green-800 border-green-300",
|
||||
CONNECTED: "bg-green-100 text-green-800 border-green-300",
|
||||
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
|
||||
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
|
||||
FAILED: "bg-red-100 text-red-800 border-red-300",
|
||||
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, string> = {
|
||||
WORKING: "🟢",
|
||||
CONNECTED: "🟢",
|
||||
SCAN_QR_CODE: "📷",
|
||||
STARTING: "🔄",
|
||||
FAILED: "🔴",
|
||||
STOPPED: "⚫",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
|
||||
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-indigo-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert({
|
||||
type,
|
||||
children,
|
||||
}: {
|
||||
type: "ok" | "err" | "info";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls =
|
||||
type === "ok"
|
||||
? "bg-green-50 text-green-800 border-green-200"
|
||||
: type === "err"
|
||||
? "bg-red-50 text-red-800 border-red-200"
|
||||
: "bg-blue-50 text-blue-800 border-blue-200";
|
||||
return (
|
||||
<div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WhatsAppAdminPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
// WhatsApp management moved into Site Settings (its own tab) — this route now
|
||||
// just redirects there so old links/bookmarks (and the backend's failure-alert
|
||||
// email, which links here) still land somewhere useful.
|
||||
export default function WhatsAppRedirectPage() {
|
||||
const router = useRouter();
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user || !isAdmin) router.replace("/dashboard");
|
||||
}, [user, loading, isAdmin, router]);
|
||||
|
||||
// ── Config state (drives wizard steps) ──────────────────────────────────────
|
||||
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
|
||||
const [cfgLoading, setCfgLoading] = useState(true);
|
||||
|
||||
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
|
||||
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
|
||||
|
||||
// ── Step 1 inputs ────────────────────────────────────────────────────────────
|
||||
const [inputToken, setInputToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
// ── Step 2 inputs ────────────────────────────────────────────────────────────
|
||||
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
|
||||
const [inputInstanceId, setInputInstanceId] = useState("");
|
||||
const [savingInstance, setSavingInstance] = useState(false);
|
||||
|
||||
// ── Step 3: session state ────────────────────────────────────────────────────
|
||||
const [status, setStatus] = useState<WAStatus | null>(null);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
const [qrSrc, setQrSrc] = useState<string | null>(null);
|
||||
const [pairingPhone, setPairingPhone] = useState("");
|
||||
|
||||
// ── Shared action feedback ───────────────────────────────────────────────────
|
||||
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
// ── Load config ──────────────────────────────────────────────────────────────
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
|
||||
setCfg(res);
|
||||
} catch {
|
||||
// network error — leave cfg null, user sees loading state
|
||||
} finally {
|
||||
setCfgLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
||||
|
||||
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!token || step !== 3) return;
|
||||
try {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
// Re-fetch config — if the session was not found, backend clears the
|
||||
// instance ID and the step recomputes to 2 (Session Instance setup).
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
}
|
||||
}, [token, step, fetchConfig]);
|
||||
|
||||
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
|
||||
|
||||
// Auto-poll status when not stable
|
||||
useEffect(() => {
|
||||
if (step !== 3 || status === null) return;
|
||||
if (ACTIVE_STATUSES.has(status)) return;
|
||||
const id = setInterval(fetchStatus, 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, [step, status, fetchStatus]);
|
||||
|
||||
// ── QR fetch ─────────────────────────────────────────────────────────────────
|
||||
const fetchQr = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
|
||||
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
|
||||
} catch {
|
||||
setQrSrc(null);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "SCAN_QR_CODE") { fetchQr(); }
|
||||
else { setQrSrc(null); }
|
||||
}, [status, fetchQr]);
|
||||
|
||||
// Auto-refresh QR every 20s while waiting
|
||||
useEffect(() => {
|
||||
if (status !== "SCAN_QR_CODE") return;
|
||||
const id = setInterval(fetchQr, 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [status, fetchQr]);
|
||||
|
||||
// ── Generic session action ───────────────────────────────────────────────────
|
||||
const doAction = async (action: string, body?: object) => {
|
||||
if (!token) return;
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body,
|
||||
});
|
||||
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||
await fetchStatus();
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
let msg = e?.message || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
|
||||
// the wizard steps back to Step 2; no need to show an error message.
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
setActionMsg({ type: "err", text: msg });
|
||||
}
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 1: Save token ──────────────────────────────────────────────────────
|
||||
const saveToken = async () => {
|
||||
if (!inputToken.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: inputToken.trim(), instanceId: "" },
|
||||
});
|
||||
setInputToken("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
|
||||
const saveInstanceId = async () => {
|
||||
if (!inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
|
||||
return;
|
||||
}
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: "", instanceId: inputInstanceId.trim() },
|
||||
// token left blank → backend keeps existing token
|
||||
});
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Step 2: Create new instance ─────────────────────────────────────────────
|
||||
const createInstance = async () => {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
});
|
||||
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Pairing code ────────────────────────────────────────────────────────────
|
||||
const requestPairingCode = async () => {
|
||||
if (!pairingPhone.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter your phone number first." });
|
||||
return;
|
||||
}
|
||||
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
|
||||
};
|
||||
|
||||
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
|
||||
const resetToken = async () => {
|
||||
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: { token: "_clear_", instanceId: "" },
|
||||
});
|
||||
} catch {}
|
||||
// Force a re-read — even if the above fails, clear local state
|
||||
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading || cfgLoading) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner /> Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">💬</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
|
||||
<p className="text-sm text-gray-500">Powered by WAWP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<StepIndicator step={step} />
|
||||
|
||||
{/* Global action message */}
|
||||
{actionMsg && (
|
||||
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
|
||||
)}
|
||||
|
||||
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Step 1 — Enter your WAWP Access Token</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Your access token is found in your WAWP account dashboard at{" "}
|
||||
<a
|
||||
href="https://app.wawp.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
app.wawp.net
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-gray-700">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputToken}
|
||||
onChange={e => setInputToken(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveToken()}
|
||||
placeholder="Paste your WAWP access token"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveToken}
|
||||
disabled={savingToken}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Token & Continue"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Step 2 — Set Up Session Instance</h2>
|
||||
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
Token: {cfg?.tokenMasked}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
You need a WAWP session instance. Either create a brand-new one, or enter an
|
||||
existing Instance ID.
|
||||
</p>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setInstanceMode("create")}
|
||||
className={`flex-1 px-4 py-2.5 transition-colors ${
|
||||
instanceMode === "create"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Create new instance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInstanceMode("enter")}
|
||||
className={`flex-1 px-4 py-2.5 border-l transition-colors ${
|
||||
instanceMode === "enter"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Enter existing ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{instanceMode === "create" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
Click below to create a new WAWP session. The Instance ID will be saved
|
||||
automatically.
|
||||
</p>
|
||||
<button
|
||||
onClick={createInstance}
|
||||
disabled={savingInstance}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Creating…" : "Create Instance"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instanceMode === "enter" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Instance ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputInstanceId}
|
||||
onChange={e => setInputInstanceId(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
|
||||
placeholder="e.g. BF14B761C364"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveInstanceId}
|
||||
disabled={savingInstance}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingInstance && <Spinner />}
|
||||
{savingInstance ? "Saving…" : "Save & Continue"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={resetToken}
|
||||
className="text-xs text-gray-400 hover:text-red-500 hover:underline"
|
||||
>
|
||||
← Change access token
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── STEP 3: Full management ─────────────────────────────────────────── */}
|
||||
{step === 3 && (
|
||||
<>
|
||||
{/* Status card */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Session Status</h2>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="text-xs text-indigo-600 hover:underline"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner /> Fetching status…
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${
|
||||
STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
|
||||
|
||||
{status === "FAILED" && (
|
||||
<Alert type="err">
|
||||
The session has failed. The system will attempt to auto-restart. You can also
|
||||
restart manually below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status && POLLING_STATUSES.has(status) && (
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Spinner /> Auto-refreshing every 5 seconds…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Config info strip */}
|
||||
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
|
||||
<span>
|
||||
Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span>
|
||||
</span>
|
||||
<span>
|
||||
Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* QR Code */}
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Scan QR Code</h2>
|
||||
<button
|
||||
onClick={fetchQr}
|
||||
className="text-xs text-indigo-600 hover:underline"
|
||||
>
|
||||
Refresh QR
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Open WhatsApp → Linked Devices → Link a Device, then scan the code below.
|
||||
</p>
|
||||
{qrSrc ? (
|
||||
<img
|
||||
src={qrSrc}
|
||||
alt="WhatsApp QR Code"
|
||||
className="w-56 h-56 border rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Spinner /> Loading QR…
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">
|
||||
QR codes expire after ~20 seconds — click Refresh QR if it stops working.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Pairing code */}
|
||||
{status === "SCAN_QR_CODE" && (
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing
|
||||
code on your phone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="tel"
|
||||
value={pairingPhone}
|
||||
onChange={e => setPairingPhone(e.target.value)}
|
||||
placeholder="082 123 4567"
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={requestPairingCode}
|
||||
disabled={busy === "request-code"}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "request-code" && <Spinner />}
|
||||
{busy === "request-code" ? "Sending…" : "Send code"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Session controls */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Session Controls</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Start"
|
||||
busyLabel="Starting…"
|
||||
isBusy={busy === "start"}
|
||||
disabled={!!busy}
|
||||
color="green"
|
||||
onClick={() => doAction("start")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
busyLabel="Restarting…"
|
||||
isBusy={busy === "restart"}
|
||||
disabled={!!busy}
|
||||
color="amber"
|
||||
onClick={() => doAction("restart")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logout"
|
||||
busyLabel="Logging out…"
|
||||
isBusy={busy === "logout"}
|
||||
disabled={!!busy}
|
||||
color="red-outline"
|
||||
onClick={() => {
|
||||
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
|
||||
doAction("logout");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Instance management */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-semibold">Instance Management</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Create a brand-new instance or permanently delete the current one. Deleting will
|
||||
require you to set up a new instance.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Create New Instance"
|
||||
busyLabel="Creating…"
|
||||
isBusy={busy === "create-instance"}
|
||||
disabled={!!busy}
|
||||
color="blue"
|
||||
onClick={() => doAction("create-instance")}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Delete Instance"
|
||||
busyLabel="Deleting…"
|
||||
isBusy={busy === "delete-instance"}
|
||||
disabled={!!busy}
|
||||
color="red-outline"
|
||||
onClick={() => {
|
||||
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
|
||||
doAction("delete-instance");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Update credentials */}
|
||||
<details className="border rounded-xl bg-white shadow-sm">
|
||||
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
|
||||
<span>Update Credentials</span>
|
||||
<span className="text-gray-400 text-xs">expand ▾</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-5 space-y-3 border-t pt-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Change your WAWP access token or Instance ID. Leave a field blank to keep the
|
||||
current value.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
New Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={inputToken}
|
||||
onChange={e => setInputToken(e.target.value)}
|
||||
placeholder="Leave blank to keep current token"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
New Instance ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputInstanceId}
|
||||
onChange={e => setInputInstanceId(e.target.value)}
|
||||
placeholder="Leave blank to keep current instance"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!inputToken.trim() && !inputInstanceId.trim()) {
|
||||
setActionMsg({ type: "err", text: "Enter at least one field to update." });
|
||||
return;
|
||||
}
|
||||
setSavingToken(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
await apiFetch("/api/whatsapp/config", {
|
||||
method: "POST",
|
||||
authToken: token!,
|
||||
body: {
|
||||
token: inputToken.trim() || undefined,
|
||||
instanceId: inputInstanceId.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setActionMsg({ type: "ok", text: "Credentials updated." });
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
}}
|
||||
disabled={savingToken}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{savingToken && <Spinner />}
|
||||
{savingToken ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
WhatsApp notifications powered by{" "}
|
||||
<a
|
||||
href="https://wawp.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
WAWP
|
||||
</a>
|
||||
. Session auto-recovers on failure; admin alert sent if recovery fails.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepIndicator({ step }: { step: number }) {
|
||||
const steps = [
|
||||
{ n: 1, label: "Access Token" },
|
||||
{ n: 2, label: "Session Instance" },
|
||||
{ n: 3, label: "Connected" },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-0">
|
||||
{steps.map((s, i) => {
|
||||
const done = step > s.n;
|
||||
const current = step === s.n;
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
|
||||
done
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: current
|
||||
? "bg-indigo-600 border-indigo-600 text-white"
|
||||
: "bg-white border-gray-300 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{done ? "✓" : s.n}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
done || current ? "text-gray-700" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
|
||||
done ? "bg-green-400" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
busyLabel,
|
||||
isBusy,
|
||||
disabled,
|
||||
color,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
isBusy: boolean;
|
||||
disabled: boolean;
|
||||
color: ButtonColor;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
|
||||
const colors: Record<ButtonColor, string> = {
|
||||
green: "bg-green-600 text-white hover:bg-green-700",
|
||||
amber: "bg-amber-500 text-white hover:bg-amber-600",
|
||||
blue: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
|
||||
};
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
|
||||
{isBusy && <Spinner />}
|
||||
{isBusy ? busyLabel : label}
|
||||
</button>
|
||||
);
|
||||
router.replace("/dashboard/admin/settings?tab=whatsapp");
|
||||
}, [router]);
|
||||
return <div className="p-6 text-sm text-gray-500">Redirecting…</div>;
|
||||
}
|
||||
@@ -7,6 +7,19 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
// The sidebar shows only on these exact routes — each role's dashboard root,
|
||||
// plus Profile & Security and Site Settings (matching the redesign
|
||||
// mockups). Every other /dashboard/* route relies on the global floating
|
||||
// help button instead of sidebar nav, same as before this redesign.
|
||||
const SIDEBAR_ROUTES = [
|
||||
"/dashboard/admin",
|
||||
"/dashboard/supervisor",
|
||||
"/dashboard/staff",
|
||||
"/dashboard/user",
|
||||
"/dashboard/user/profile",
|
||||
"/dashboard/admin/settings",
|
||||
];
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -63,31 +76,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-pages within staff/supervisor/admin (e.g. /dashboard/supervisor/reports,
|
||||
// /dashboard/admin/cashup/[id]) are full-width workspaces with their own internal navigation
|
||||
// and header — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would
|
||||
// just crowd them. The sidebar stays visible only on each role's root landing page; every
|
||||
// deeper sub-page hides it. Navigating back is never a dead end: Navbar's "Dashboard" link is
|
||||
// always present and /dashboard auto-redirects to the role root.
|
||||
const SIDEBAR_ROOTS = ["/dashboard/staff", "/dashboard/supervisor", "/dashboard/admin"];
|
||||
const hideSidebar = !!pathname && SIDEBAR_ROOTS.some(root => pathname !== root && pathname.startsWith(root + "/"));
|
||||
const showSidebar = !!pathname && SIDEBAR_ROUTES.includes(pathname);
|
||||
|
||||
if (showSidebar) {
|
||||
// App-shell layout: the whole viewport is claimed (h-screen, no body
|
||||
// scroll) so the sidebar can be a plain sibling column that never
|
||||
// scrolls — only the content column (main + footer) scrolls internally.
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<Navbar />
|
||||
<MobileSidebar />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<Sidebar />
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1 flex flex-col md:flex-row">
|
||||
{!hideSidebar && (
|
||||
<>
|
||||
{/* Mobile dropdown navigation */}
|
||||
<MobileSidebar />
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
</div>
|
||||
<StatCardRow>
|
||||
<StatCard icon={Activity} label="Scans today" value={stats ? stats.totalToday : "—"} tone="brand" />
|
||||
<StatCard icon={UserCheck} label="My scans today" value={stats ? stats.myToday : "—"} tone="blue" />
|
||||
<StatCard icon={Clock} label="Last hour" value={stats ? stats.lastHour : "—"} tone="amber" />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
@@ -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,28 +120,8 @@ export default function StaffDashboardPage() {
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats…</div>}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">{stats.totalToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">My scans</div>
|
||||
<div className="text-lg font-semibold">{stats.myToday}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Last hour</div>
|
||||
<div className="text-lg font-semibold">{stats.lastHour}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
@@ -142,7 +130,8 @@ export default function StaffDashboardPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No scans recorded yet today.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QrCode } from "lucide-react";
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Ticket Scanning</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<QrCode className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Use the button to start/stop scanning. The back camera will be used when available.
|
||||
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
|
||||
{confirmModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
|
||||
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
|
||||
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
@@ -382,7 +388,7 @@ export default function TicketScanningPage() {
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={confirmModal.remaining}
|
||||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
value={confirmQtyRaw}
|
||||
onChange={e => setConfirmQtyRaw(e.target.value)}
|
||||
onBlur={() => {
|
||||
@@ -404,7 +410,7 @@ export default function TicketScanningPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
|
||||
@@ -17,6 +18,14 @@ const MODE_LABELS: Record<Mode, string> = {
|
||||
refund: "REFUND",
|
||||
};
|
||||
|
||||
const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||||
registration: UserPlus,
|
||||
payment: CreditCard,
|
||||
checkin: CheckSquare,
|
||||
tickets: Ticket,
|
||||
refund: RotateCcw,
|
||||
};
|
||||
|
||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
@@ -343,7 +352,12 @@ export default function AtTheDoorPage() {
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||
<h1 className="text-2xl font-semibold shrink-0">At The Door</h1>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<DoorOpen className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
|
||||
</div>
|
||||
<select
|
||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||||
value={eventId}
|
||||
@@ -384,21 +398,25 @@ export default function AtTheDoorPage() {
|
||||
|
||||
{/* Mode Buttons */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
|
||||
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
|
||||
const Icon = MODE_ICONS[m];
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-4 py-2 rounded text-sm font-medium border ${
|
||||
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded text-sm font-medium border ${
|
||||
mode === m
|
||||
? m === "refund"
|
||||
? "bg-red-600 text-white border-red-600"
|
||||
: "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-brand-600 text-white border-brand-600"
|
||||
: "bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ✅ Panels */}
|
||||
@@ -538,7 +556,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-indigo-600 text-white">Open</button>
|
||||
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white">Open</button>
|
||||
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
|
||||
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
|
||||
</div>
|
||||
@@ -712,7 +730,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
{balance > 0 && (
|
||||
<button
|
||||
onClick={() => setAmount(String(balance))}
|
||||
className="px-4 rounded bg-indigo-600 text-white text-sm font-medium"
|
||||
className="px-4 rounded bg-brand-600 text-white text-sm font-medium"
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
@@ -747,7 +765,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => onSuccess(registration)}
|
||||
className="flex-1 py-3 rounded bg-indigo-600 text-white text-sm font-medium"
|
||||
className="flex-1 py-3 rounded bg-brand-600 text-white text-sm font-medium"
|
||||
>
|
||||
Print Tickets
|
||||
</button>
|
||||
@@ -1113,7 +1131,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
<button
|
||||
onClick={() => commit(t)}
|
||||
disabled={submittingId === t.id}
|
||||
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||
className="ml-auto px-4 py-2 text-sm rounded bg-brand-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
|
||||
</button>
|
||||
@@ -1175,7 +1193,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<div key={opt.id} className="border rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-50 border-b font-medium text-sm">
|
||||
{opt.name}
|
||||
{opt.isMainTicket && <span className="ml-1.5 text-xs text-blue-600 font-normal">• Main</span>}
|
||||
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span>}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
@@ -1424,7 +1442,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
className={`flex-1 py-2 rounded border text-sm font-medium ${
|
||||
channel === c
|
||||
? c === "whatsapp" ? "bg-green-600 text-white border-green-600"
|
||||
: c === "both" ? "bg-indigo-600 text-white border-indigo-600"
|
||||
: c === "both" ? "bg-brand-600 text-white border-brand-600"
|
||||
: "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
@@ -1472,7 +1490,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
className="flex-1 py-2 rounded bg-indigo-600 text-white text-sm font-medium disabled:opacity-60"
|
||||
className="flex-1 py-2 rounded bg-brand-600 text-white text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{sending ? "Sending…" : "Send Tickets"}
|
||||
</button>
|
||||
@@ -1872,7 +1890,7 @@ function NewAttendeeModal({ open, onClose, seed, onConfirm }: {
|
||||
className={`flex-1 py-2 transition-colors ${
|
||||
notifPref === p
|
||||
? p === "whatsapp" ? "bg-green-600 text-white"
|
||||
: p === "both" ? "bg-indigo-600 text-white"
|
||||
: p === "both" ? "bg-brand-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
|
||||
@@ -84,7 +85,7 @@ function AttendeesCheckboxDropdown({
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-0.5 rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50"
|
||||
className="text-xs px-2 py-0.5 rounded border border-brand-300 text-brand-700 hover:bg-brand-50"
|
||||
onClick={() => onChange(attendees.filter(a => prefMatch(a.pref)).map(a => a.id))}
|
||||
>
|
||||
Select {channel === "email" ? "Email/both" : "WhatsApp/both"}
|
||||
@@ -114,7 +115,7 @@ function AttendeesCheckboxDropdown({
|
||||
<span className="truncate flex-1">
|
||||
{a.name ? `${a.name} <${a.email}>` : a.email}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-indigo-700 bg-indigo-50" : "text-amber-700 bg-amber-50"}`}>
|
||||
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-brand-700 bg-brand-50" : "text-amber-700 bg-amber-50"}`}>
|
||||
{prefLabel(a.pref)}
|
||||
</span>
|
||||
</label>
|
||||
@@ -260,7 +261,7 @@ function EmailAttendeesPageInner() {
|
||||
};
|
||||
|
||||
// Scheduled jobs state
|
||||
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean };
|
||||
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; channel?: string; recipient?: string|null; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; html?: string; text?: string; hasHtml?: boolean; hasText?: boolean };
|
||||
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
||||
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
||||
@@ -273,7 +274,7 @@ function EmailAttendeesPageInner() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails`, { authToken: token });
|
||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch (e) {
|
||||
// ignore here; surfaces via UI when tab open
|
||||
@@ -287,7 +288,7 @@ function EmailAttendeesPageInner() {
|
||||
const openEdit = (job: ScheduledJob) => {
|
||||
setEditing(job);
|
||||
setEditSubject(job.subject || '');
|
||||
setEditBody(''); // body not included in list; will let user set a new one if needed
|
||||
setEditBody(job.html || job.text || '');
|
||||
try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); }
|
||||
};
|
||||
|
||||
@@ -479,10 +480,15 @@ function EmailAttendeesPageInner() {
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Email Attendees</h1>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Email Attendees</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -497,20 +503,24 @@ function EmailAttendeesPageInner() {
|
||||
|
||||
{/* Tabs like on Payments page */}
|
||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
|
||||
<Users className="w-4 h-4" />
|
||||
Attendees
|
||||
</label>
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
|
||||
<Zap className="w-4 h-4" />
|
||||
Automations
|
||||
</label>
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
|
||||
<Mail className="w-4 h-4" />
|
||||
Broadcasts
|
||||
</label>
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
|
||||
<Clock className="w-4 h-4" />
|
||||
Scheduled
|
||||
</label>
|
||||
</div>
|
||||
@@ -636,7 +646,7 @@ function EmailAttendeesPageInner() {
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>Preview recipients</button>
|
||||
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
|
||||
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
|
||||
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
@@ -776,7 +786,7 @@ Jane Doe <jane@example.com>
|
||||
}
|
||||
}}>Preview recipients</button>
|
||||
|
||||
<button type="button" className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={async () => {
|
||||
<button type="button" className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={async () => {
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
@@ -866,7 +876,7 @@ Jane Doe <jane@example.com>
|
||||
{loadingScheduled ? (
|
||||
<div className="text-sm text-gray-600">Loading…</div>
|
||||
) : scheduled.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">No scheduled items. Items sent more than a week ago are hidden.</div>
|
||||
<div className="text-sm text-gray-600">No scheduled emails. Sent items are cleared 24 hours after sending.</div>
|
||||
) : (
|
||||
<ul className="divide-y border rounded">
|
||||
{scheduled.map(job => (
|
||||
@@ -876,6 +886,16 @@ Jane Doe <jane@example.com>
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span>
|
||||
<span className="truncate">{job.subject || '(no subject)'}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1 truncate">
|
||||
{(() => {
|
||||
const body = (job.html || job.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!body) return '(no content)';
|
||||
return body.length > 80 ? body.slice(0, 80) + '…' : body;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<span className="mr-2">To: {job.recipient || 'Unknown recipients'}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<span className="mr-2">Status: {job.status}</span>
|
||||
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
|
||||
@@ -915,7 +935,7 @@ Jane Doe <jane@example.com>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={e => setEditWhen(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
|
||||
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
|
||||
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Ticket } from "lucide-react";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -85,8 +86,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addRow}>Add tier</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -202,9 +203,14 @@ function EventOptionsContent() {
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Event options</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Ticket className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Event options</h1>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -280,7 +286,7 @@ function EventOptionsContent() {
|
||||
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
|
||||
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
|
||||
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
|
||||
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
|
||||
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm">Create option</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,7 +87,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
|
||||
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
|
||||
}} />
|
||||
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
|
||||
className="px-3 py-1.5 text-sm rounded border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50">
|
||||
className="px-3 py-1.5 text-sm rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
|
||||
{uploading ? "Uploading…" : label}
|
||||
</button>
|
||||
</div>
|
||||
@@ -120,7 +121,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
{fields.map((f, idx) => (
|
||||
<li key={idx} className="bg-white border rounded p-2">
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium">{typeLabel(f.type)}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium">{typeLabel(f.type)}</span>
|
||||
<span className="text-xs text-gray-400">#{idx + 1}</span>
|
||||
<div className="flex-1" />
|
||||
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => mov(idx, -1)} disabled={idx === 0}>↑</button>
|
||||
@@ -149,7 +150,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => add("text")}>+ Question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
|
||||
</div>
|
||||
@@ -204,7 +205,7 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={add}>+ Add option</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={add}>+ Add option</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -254,7 +255,7 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addVariant}>+ Add variant</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addVariant}>+ Add variant</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -315,7 +316,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
|
||||
onChange={updated => { const c = opt.earlyBirdTiers.slice(); c[ti] = updated; updTiers(c); }}
|
||||
onRemove={() => { const c = opt.earlyBirdTiers.slice(); c.splice(ti, 1); updTiers(c); }} />
|
||||
))}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -350,7 +351,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
|
||||
onChange={updated => { const c = v.earlyBirdTiers.slice(); c[ti] = updated; updVTiers(c); }}
|
||||
onRemove={() => { const c = v.earlyBirdTiers.slice(); c.splice(ti, 1); updVTiers(c); }} />
|
||||
))}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -414,7 +415,7 @@ function SectionsDraftEditor({ sections, options, onChange }: {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -479,7 +480,7 @@ function SectionsManager({ eventId }: { eventId: string }) {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -501,7 +502,7 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={onDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +558,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
|
||||
<ul className="space-y-1">
|
||||
{items.map(it => (
|
||||
<li key={it.id} className="flex items-center justify-between text-xs">
|
||||
<a href={it.url} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline truncate max-w-xs">{it.originalName}</a>
|
||||
<a href={it.url} target="_blank" rel="noreferrer" className="text-brand-600 hover:underline truncate max-w-xs">{it.originalName}</a>
|
||||
<button type="button" className="text-red-600 hover:underline ml-2" onClick={() => del(it.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -671,7 +672,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
|
||||
className="border rounded bg-white shadow-lg overflow-y-auto"
|
||||
>
|
||||
{visibleResults.map(u => (
|
||||
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-indigo-50 cursor-pointer" onClick={() => add(u)}>
|
||||
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-brand-50 cursor-pointer" onClick={() => add(u)}>
|
||||
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -1140,7 +1141,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<button key={i} type="button"
|
||||
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
|
||||
className={`px-3 py-1.5 text-xs rounded border whitespace-nowrap transition-colors ${
|
||||
step === i ? "bg-indigo-600 text-white border-indigo-600"
|
||||
step === i ? "bg-brand-600 text-white border-brand-600"
|
||||
: clickable ? "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 cursor-pointer"
|
||||
: "bg-white text-gray-400 border-gray-100 cursor-default"
|
||||
}`}
|
||||
@@ -1156,7 +1157,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<button key={i} type="button"
|
||||
onClick={() => setPricingSubstep(i as PricingSubstep)}
|
||||
className={`px-3 py-1 text-xs rounded border whitespace-nowrap transition-colors ${
|
||||
pricingSubstep === i ? "bg-indigo-100 text-indigo-700 border-indigo-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
|
||||
pricingSubstep === i ? "bg-brand-100 text-brand-700 border-brand-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>{s}</button>
|
||||
))}
|
||||
@@ -1344,13 +1345,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{mode === "create" ? (
|
||||
isLastStep && isOnLastSubstep ? (
|
||||
<button type="button" disabled={saving} onClick={handleSave}
|
||||
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
|
||||
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{saving ? "Creating…" : "Create Event"}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" disabled={nextDisabled} onClick={handleNext}
|
||||
title={basicDetailsIncomplete ? "Fill in title, start, end date, and base price to continue" : optionsIncomplete ? "Every option needs a price before continuing" : undefined}
|
||||
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-indigo-600">Next →</button>
|
||||
className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-brand-600">Next →</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
@@ -1360,7 +1361,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
<button type="button" disabled={saving || isClosed} onClick={handleSave}
|
||||
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
|
||||
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
|
||||
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</>
|
||||
@@ -1379,7 +1380,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
|
||||
const isInactive = ev.isActive === false;
|
||||
const isClosed = ev.cashupStatus === "closed";
|
||||
return (
|
||||
<li className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
|
||||
<li className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{ev.title}</span>
|
||||
@@ -1393,7 +1394,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
|
||||
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-indigo-600 shrink-0 mt-0.5">Edit →</span>
|
||||
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit →</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1461,14 +1462,19 @@ export default function ManageEventsPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Calendar className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Events</h1>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
|
||||
|
||||
@@ -55,7 +56,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
{fields.map((f, idx) => (
|
||||
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium shrink-0">{typeLabel(f.type)}</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
|
||||
<div className="flex-1" />
|
||||
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up">↑</button>
|
||||
@@ -109,7 +110,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => addField('text')}>+ Add question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => addField('text')}>+ Add question</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
|
||||
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
|
||||
</div>
|
||||
@@ -438,12 +439,17 @@ export default function FormsBrowserPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4 no-print">
|
||||
<h1 className="text-2xl font-semibold">Forms</h1>
|
||||
<div className="flex items-center justify-between mb-4 no-print flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<FileText className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Forms</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
{mode === 'view' && (
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={onPrint} disabled={items.length === 0}>
|
||||
Print forms
|
||||
</button>
|
||||
)}
|
||||
@@ -459,7 +465,7 @@ export default function FormsBrowserPage() {
|
||||
{/* Mode tabs */}
|
||||
<div className="mb-4 flex items-center gap-2 no-print">
|
||||
{(['view', 'fill', 'manage'] as const).map(m => (
|
||||
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
|
||||
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
|
||||
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
|
||||
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
|
||||
</label>
|
||||
@@ -511,7 +517,7 @@ export default function FormsBrowserPage() {
|
||||
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
|
||||
/>
|
||||
{userId && (
|
||||
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
|
||||
<div className="text-xs text-brand-600 mt-0.5 truncate max-w-xs">
|
||||
{allUsers.find(u => u.id === userId)?.name || userId}
|
||||
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}>✕</button>
|
||||
</div>
|
||||
@@ -521,7 +527,7 @@ export default function FormsBrowserPage() {
|
||||
{filteredUsers.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-brand-50 flex flex-col"
|
||||
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
|
||||
>
|
||||
<span className="font-medium truncate">{u.name || "(no name)"}</span>
|
||||
@@ -538,7 +544,7 @@ export default function FormsBrowserPage() {
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700"
|
||||
onClick={() => search(false)}
|
||||
disabled={loadingList}
|
||||
>
|
||||
@@ -634,7 +640,7 @@ export default function FormsBrowserPage() {
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
onClick={saveForm}
|
||||
disabled={formLoading}
|
||||
>
|
||||
@@ -719,7 +725,7 @@ export default function FormsBrowserPage() {
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
onClick={saveResponses}
|
||||
disabled={savingFill || mainTicketCount === 0}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
|
||||
|
||||
@@ -61,7 +62,12 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">Event ID</label>
|
||||
@@ -96,7 +102,7 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||
{busy ? "Submitting..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -198,7 +204,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
|
||||
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -337,9 +338,14 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Manual registration</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Manual registration</h1>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -349,12 +355,14 @@ export default function ManualRegistrationPage() {
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Register
|
||||
</label>
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Record Payment
|
||||
</label>
|
||||
</div>
|
||||
@@ -395,7 +403,7 @@ export default function ManualRegistrationPage() {
|
||||
Search existing user <span className="font-normal">(name, email or phone)</span>
|
||||
</label>
|
||||
<input
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Start typing to find a user…"
|
||||
value={userQuery}
|
||||
autoComplete="off"
|
||||
@@ -414,7 +422,7 @@ export default function ManualRegistrationPage() {
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
onClick={() => selectUser(u)}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
||||
@@ -443,13 +451,13 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<input
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Full name"
|
||||
value={guest.name}
|
||||
onChange={e => setGuest({ ...guest, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
|
||||
type="email"
|
||||
value={guest.email}
|
||||
@@ -457,7 +465,7 @@ export default function ManualRegistrationPage() {
|
||||
required={!registerAsGuest}
|
||||
/>
|
||||
<input
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Phone (optional)"
|
||||
value={guest.phoneNumber}
|
||||
onChange={e => {
|
||||
@@ -491,7 +499,7 @@ export default function ManualRegistrationPage() {
|
||||
className={`flex-1 py-2 transition-colors ${
|
||||
notifPref === p
|
||||
? p === "whatsapp" ? "bg-green-600 text-white border-green-600"
|
||||
: p === "both" ? "bg-indigo-600 text-white"
|
||||
: p === "both" ? "bg-brand-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
@@ -529,7 +537,7 @@ export default function ManualRegistrationPage() {
|
||||
return (
|
||||
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
|
||||
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
|
||||
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-blue-600 font-normal">• Main</span> : null}
|
||||
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span> : null}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
@@ -586,7 +594,7 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
|
||||
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
|
||||
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -615,7 +623,7 @@ export default function ManualRegistrationPage() {
|
||||
<div ref={paySearchRef} className="relative">
|
||||
<label className="block text-xs text-gray-600 mb-1">User</label>
|
||||
<input
|
||||
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
placeholder="Search by name, email or phone…"
|
||||
value={payUserQuery}
|
||||
autoComplete="off"
|
||||
@@ -633,7 +641,7 @@ export default function ManualRegistrationPage() {
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
onClick={() => selectPayUser(u)}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
||||
@@ -669,7 +677,7 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
|
||||
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-2">Quick actions</div>
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
|
||||
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
|
||||
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
|
||||
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
|
||||
<div className="text-xs text-white/90">Manual payments and assignment</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
|
||||
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
|
||||
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
|
||||
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
|
||||
Reports
|
||||
<div className="text-xs text-white/90">View, export, and email reports</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
|
||||
Attendee forms
|
||||
<div className="text-xs text-white/90">View submitted attendee forms</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
|
||||
Email attendees
|
||||
<div className="text-xs text-white/90">Send message to attendees of an event</div>
|
||||
</button>
|
||||
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
|
||||
WhatsApp attendees
|
||||
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
|
||||
</button>
|
||||
</div>
|
||||
<StatCardRow>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
<div className="text-lg font-semibold mb-3">Quick actions</div>
|
||||
<QuickActionGrid>
|
||||
{QUICK_ACTIONS.map(a => (
|
||||
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
|
||||
))}
|
||||
</QuickActionGrid>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Revenue trend — past month</h2>
|
||||
{overview && overview.trend.length > 0 ? (
|
||||
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
|
||||
{overview && overview.topEvents.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead className="text-right">Registrations</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Tickets sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.topEvents.map(e => (
|
||||
<TableRow key={e.eventId}>
|
||||
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
|
||||
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
|
||||
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent scans</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
@@ -138,21 +181,23 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}</div>
|
||||
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}</div>
|
||||
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
|
||||
</li>
|
||||
))}
|
||||
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 min-w-0">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Scanner activity</h2>
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
{scanStats ? (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Today</div>
|
||||
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
|
||||
@@ -169,9 +214,21 @@ export default function SupervisorDashboardPage() {
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No scanner data yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Payments today</h2>
|
||||
{paymentStats ? (
|
||||
<div className="border rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">Today</div>
|
||||
<div className="text-base font-semibold">{formatRand(paymentStats.totalToday)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
@@ -185,30 +242,6 @@ export default function SupervisorDashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
|
||||
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading…</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded p-3 bg-white flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Active events</div>
|
||||
<div className="text-lg font-semibold">{activeEventsCount}</div>
|
||||
</div>
|
||||
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Revenue today</div>
|
||||
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<div className="text-xs text-gray-500">Donations today</div>
|
||||
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
@@ -16,6 +17,18 @@ 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[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
@@ -73,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}
|
||||
@@ -90,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>}
|
||||
@@ -514,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');
|
||||
}
|
||||
@@ -533,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 && (
|
||||
@@ -547,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>
|
||||
@@ -605,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;
|
||||
@@ -645,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>
|
||||
@@ -736,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>
|
||||
)}
|
||||
@@ -790,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>
|
||||
@@ -800,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>
|
||||
@@ -879,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);
|
||||
@@ -897,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]);
|
||||
|
||||
@@ -914,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', {
|
||||
@@ -928,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');
|
||||
@@ -981,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>
|
||||
@@ -1041,12 +1075,15 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// 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.
|
||||
// (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) + (p.amount || 0));
|
||||
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
|
||||
}
|
||||
});
|
||||
return m;
|
||||
@@ -1177,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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { Suspense } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ReportsV2 from "@/components/reports/ReportsV2";
|
||||
|
||||
@@ -8,7 +8,9 @@ export default function SupervisorReportsPage() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="w-full p-6">
|
||||
<Suspense fallback={<div className="text-sm text-gray-400">Loading…</div>}>
|
||||
<ReportsV2 onBack={() => router.push('/dashboard')} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,19 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
type UserEntry = { id: string; name: string; phone: string; pref: string };
|
||||
|
||||
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
|
||||
attendees: Users,
|
||||
automations: Zap,
|
||||
broadcasts: Send,
|
||||
scheduled: Clock,
|
||||
};
|
||||
|
||||
function toLocalInputValue(d: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
@@ -534,7 +542,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
};
|
||||
|
||||
// ── Scheduled tab ──────────────────────────────────────────────────────────
|
||||
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
|
||||
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; recipient?: string | null; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; message?: string };
|
||||
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
|
||||
const [loadingScheduled, setLoadingScheduled] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledJob | null>(null);
|
||||
@@ -546,10 +554,8 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
|
||||
// Filter to only WhatsApp jobs
|
||||
const all = Array.isArray(res?.jobs) ? res.jobs : [];
|
||||
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
|
||||
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch { } finally { setLoadingScheduled(false); }
|
||||
};
|
||||
|
||||
@@ -562,7 +568,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
const body: any = {};
|
||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||
if (editMessage.trim()) body.text = editMessage;
|
||||
if (editMessage.trim()) body.message = editMessage;
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
|
||||
setInfo("Scheduled message updated.");
|
||||
setEditing(null);
|
||||
@@ -590,9 +596,14 @@ function WhatsAppAttendeesPageInner() {
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">WhatsApp Attendees</h1>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
@@ -608,12 +619,16 @@ function WhatsAppAttendeesPageInner() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
|
||||
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
|
||||
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => {
|
||||
const Icon = WA_TAB_ICONS[t];
|
||||
return (
|
||||
<label key={t} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
|
||||
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</label>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Attendees Tab ── */}
|
||||
@@ -876,7 +891,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
{loadingScheduled ? (
|
||||
<div className="text-sm text-gray-600">Loading…</div>
|
||||
) : scheduled.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
|
||||
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Sent items are cleared 24 hours after sending.</div>
|
||||
) : (
|
||||
<ul className="divide-y border rounded">
|
||||
{scheduled.map((job) => (
|
||||
@@ -884,7 +899,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
|
||||
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
|
||||
<span className="truncate text-gray-700">{job.message ? String(job.message).slice(0, 60) + (String(job.message).length > 60 ? "…" : "") : "(no message)"}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<span className="mr-2">To: {job.recipient || "Unknown recipients"}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<span className="mr-2">Status: {job.status}</span>
|
||||
@@ -895,7 +913,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
|
||||
onClick={() => { setEditing(job); setEditMessage(job.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { HandHeart } from "lucide-react";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
@@ -57,16 +58,26 @@ export default function DonatePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto w-full p-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center shrink-0">
|
||||
<HandHeart className="w-5 h-5 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
|
||||
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
|
||||
</select>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">Amount (R)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
@@ -74,15 +85,16 @@ export default function DonatePage() {
|
||||
placeholder="Enter amount (min R15)"
|
||||
value={amount}
|
||||
onChange={(e)=>setAmount(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2 mb-1"
|
||||
className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
|
||||
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
|
||||
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={submit}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,57 +182,55 @@ export default function UserProfilePage() {
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
|
||||
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
|
||||
<h1 className="text-2xl font-semibold">Profile & Security</h1>
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
|
||||
|
||||
{/* ── Profile info ─────────────────────────────────────────────────── */}
|
||||
const SectionHeader = ({ icon: Icon, title, tone = "brand" }: { icon: typeof User; title: string; tone?: "brand" | "amber" | "red" }) => {
|
||||
const toneCls = tone === "amber" ? "bg-amber-50 text-amber-600" : tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600";
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${toneCls}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Profile & Security</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6 items-start">
|
||||
<div className="space-y-6">
|
||||
{/* ── Profile info ─────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
|
||||
<SectionHeader icon={User} title="Personal information" />
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input
|
||||
type="tel"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
placeholder="e.g. 082 123 4567"
|
||||
/>
|
||||
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={notifPref}
|
||||
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
|
||||
>
|
||||
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
|
||||
<option value="email">Email only</option>
|
||||
<option value="whatsapp">WhatsApp only</option>
|
||||
<option value="both">Email & WhatsApp</option>
|
||||
@@ -212,69 +239,69 @@ export default function UserProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<button type="submit" disabled={savingProfile} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingProfile ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
{profileMsg && <Alert msg={profileMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Password ─────────────────────────────────────────────────────── */}
|
||||
{/* ── Account activity ─────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Change password</h2>
|
||||
<SectionHeader icon={Clock} title="Account activity" />
|
||||
{loadingActivity && activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{activity.map(ev => {
|
||||
const meta = ACTIVITY_META[ev.type] || ACTIVITY_META.login;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<li key={ev.id} className="flex items-start gap-3">
|
||||
<div className="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Icon className="w-3.5 h-3.5 text-gray-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
|
||||
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* ── Password ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={Lock} title="Change password" />
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPw}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingPw ? "Changing…" : "Change password"}
|
||||
</button>
|
||||
{pwMsg && <Alert msg={pwMsg} />}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Security ─────────────────────────────────────────────────────── */}
|
||||
{/* ── Security ─────────────────────────────────────────────────── */}
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-1">Security</h2>
|
||||
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
|
||||
You will remain logged in on this device.
|
||||
@@ -289,9 +316,9 @@ export default function UserProfilePage() {
|
||||
{revokeMsg && <Alert msg={revokeMsg} />}
|
||||
</section>
|
||||
|
||||
{/* ── Danger zone ──────────────────────────────────────────────────── */}
|
||||
{/* ── Danger zone ──────────────────────────────────────────────── */}
|
||||
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
|
||||
<SectionHeader icon={Trash2} title="Close account" tone="red" />
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Closing your account will deactivate it immediately. You can also request that your personal
|
||||
information (name, email, phone number) be permanently deleted. Tickets and payment records
|
||||
@@ -362,5 +389,7 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { token } = useAuth();
|
||||
@@ -43,10 +44,15 @@ export default function ResetPasswordPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-semibold">Reset password</h1>
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<KeyRound className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
|
||||
</div>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
|
||||
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Back to dashboard</button>
|
||||
</div>
|
||||
@@ -96,7 +102,7 @@ export default function ResetPasswordPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || loading}
|
||||
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
|
||||
className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60"
|
||||
>{loading ? "Saving…" : "Update password"}</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
console.error("Error sharing:", err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share
|
||||
@@ -38,7 +38,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
console.error("Error generating QR code:", err);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
Save QR
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -49,6 +50,44 @@ function lowStockThreshold(stockLimit: number): number {
|
||||
return Math.round(stockLimit * pct);
|
||||
}
|
||||
|
||||
function RegisterCta({ event }: { event: Event }) {
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
|
||||
const notYetOpen = goLive ? now < goLive : false;
|
||||
const closed = (deadline ? now >= deadline : false) || now >= end;
|
||||
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
|
||||
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||||
|
||||
if (notYetOpen) {
|
||||
return (
|
||||
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration not yet open">
|
||||
Opens {goLive?.toLocaleString()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (closed) {
|
||||
return (
|
||||
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration closed">
|
||||
Registration closed
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (eventSoldOut) {
|
||||
return (
|
||||
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
|
||||
Sold Out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={`/register/${event.id}`} className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors">
|
||||
Register
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let event: Event;
|
||||
@@ -64,22 +103,40 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-10 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500">{formatDateTimeRange(event.startDate, event.endDate)}</p>
|
||||
<ClientActions event={event} />
|
||||
{event.picture && (
|
||||
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded" />
|
||||
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{event.picture ? (
|
||||
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded-xl" />
|
||||
) : (
|
||||
<div className="w-full aspect-video rounded-xl bg-brand-50 flex items-center justify-center">
|
||||
<Calendar className="w-12 h-12 text-brand-200" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
{event.attachments && event.attachments.length > 0 && (
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mt-6">Downloads</h2>
|
||||
<ul className="list-disc pl-5 text-gray-700">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
|
||||
<Calendar className="w-4 h-4 shrink-0" />
|
||||
{formatDateTimeRange(event.startDate, event.endDate)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ClientActions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||
|
||||
{event.attachments && event.attachments.length > 0 && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Paperclip className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Downloads</h2>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{event.attachments.map((att) => (
|
||||
<li key={att.id}>
|
||||
<a className="text-blue-600 hover:underline" href={att.url} target="_blank" rel="noopener noreferrer">
|
||||
<a className="text-brand-600 hover:underline text-sm" href={att.url} target="_blank" rel="noopener noreferrer">
|
||||
{att.originalName}
|
||||
</a>
|
||||
</li>
|
||||
@@ -87,9 +144,52 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold mt-6">Tickets</h2>
|
||||
|
||||
{/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */}
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm lg:hidden">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ticket className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||||
</div>
|
||||
<TicketList event={event} />
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm sticky top-20">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ticket className="w-4 h-4 text-gray-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
|
||||
</div>
|
||||
<TicketList event={event} />
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketList({ event }: { event: Event }) {
|
||||
const options = event.eventOptions || [];
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
|
||||
<p className="text-sm text-gray-400">Free entry — no tickets required.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{(event.eventOptions || []).map((opt) => {
|
||||
{options.map((opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
|
||||
@@ -143,9 +243,9 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTiers.length > 0 ? (
|
||||
<>
|
||||
<span className="text-indigo-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||||
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
|
||||
<span className="text-xs text-indigo-600">Early bird</span>
|
||||
<span className="text-xs text-brand-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||||
@@ -157,45 +257,5 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
|
||||
const notYetOpen = goLive ? now < goLive : false;
|
||||
const closed = (deadline ? now >= deadline : false) || now >= end;
|
||||
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
|
||||
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||||
if (notYetOpen) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration not yet open">
|
||||
Opens {goLive?.toLocaleString()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (closed) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration closed">
|
||||
Registration closed
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (eventSoldOut) {
|
||||
return (
|
||||
<button disabled className="inline-block bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||||
Sold Out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={`/register/${event.id}`} className="inline-block bg-blue-600 text-white px-4 py-2 rounded">
|
||||
Register
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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] || ""}
|
||||
|
||||
+72
-16
@@ -1,10 +1,9 @@
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { ContactSection } from "@/components/shared/ContactSection";
|
||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
|
||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
@@ -20,6 +19,13 @@ type Event = {
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
|
||||
{ icon: Calendar, title: "Easy Registration", description: "Simple and secure registration so you can focus on what matters." },
|
||||
{ icon: Users, title: "Community", description: "Connect with others and grow in a supportive and loving community." },
|
||||
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
||||
];
|
||||
|
||||
export default async function HomePage() {
|
||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
||||
const now = Date.now();
|
||||
@@ -34,34 +40,84 @@ export default async function HomePage() {
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1">
|
||||
<section className="bg-gradient-to-br from-blue-100 to-white py-16 text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to {appName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-6">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="space-x-4">
|
||||
<a href="/events" className="px-6 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700">
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-brand-50 via-white to-brand-50 py-20 text-center">
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-brand-100/60 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 w-96 h-96 rounded-full bg-brand-100/40 blur-3xl" />
|
||||
</div>
|
||||
<div className="relative max-w-2xl mx-auto px-4">
|
||||
{sorted.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full bg-white border border-brand-200 text-brand-700 shadow-sm mb-5">
|
||||
<CalendarCheck className="w-3.5 h-3.5" />
|
||||
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
View Events
|
||||
</a>
|
||||
<a href="/register" className="px-6 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50">
|
||||
Join Us
|
||||
</a>
|
||||
<JoinUsButton />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 px-4 max-w-7xl mx-auto">
|
||||
<SectionHeader title="Upcoming Events" />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50">
|
||||
<CalendarCheck className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No upcoming events right now — check back soon.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
|
||||
{sorted.slice(0, 3).map(event => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center mt-8">
|
||||
<a href="/events" className="text-blue-600 hover:underline text-sm">
|
||||
View all events →
|
||||
)}
|
||||
<div className="text-center mt-8 sm:hidden">
|
||||
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ContactSection />
|
||||
<section className="bg-gray-50 py-14 px-4">
|
||||
<div className="max-w-6xl mx-auto grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map(f => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<div key={f.title} className="text-center sm:text-left">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-50 flex items-center justify-center mb-3 mx-auto sm:mx-0">
|
||||
<Icon className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{f.title}</h3>
|
||||
<p className="text-sm text-gray-600">{f.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-12 px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-brand-50 mb-4">
|
||||
<ShieldCheck className="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Secure & reliable</h2>
|
||||
<p className="text-sm text-gray-600">Your details and payments are handled securely from registration through to the day of the event.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -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">
|
||||
<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-blue-600 hover:underline">
|
||||
<a href={`mailto:${contactEmail}`} className="text-brand-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"
|
||||
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>
|
||||
|
||||
@@ -631,7 +631,7 @@ export default function SelfServicePage() {
|
||||
type={showChangePassword ? "text" : "password"}
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
@@ -651,7 +651,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changeLoading}
|
||||
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{changeLoading ? "Verifying…" : "Confirm"}
|
||||
</button>
|
||||
@@ -678,7 +678,7 @@ export default function SelfServicePage() {
|
||||
type="email"
|
||||
value={setupEmail}
|
||||
onChange={(e) => setSetupEmail(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="supervisor@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
@@ -691,7 +691,7 @@ export default function SelfServicePage() {
|
||||
type={showSetupPassword ? "text" : "password"}
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
@@ -703,7 +703,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-60 transition mt-2"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
|
||||
>
|
||||
{setupLoading ? "Signing in…" : "Sign In"}
|
||||
</button>
|
||||
@@ -723,7 +723,7 @@ export default function SelfServicePage() {
|
||||
<select
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">— Select an event —</option>
|
||||
{events.map((ev) => (
|
||||
@@ -735,9 +735,9 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
|
||||
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-sm text-brand-800">
|
||||
<p className="font-semibold">{selectedEvent.title}</p>
|
||||
<p className="text-blue-600">{fmtDate(selectedEvent.startDate)} — {fmtDate(selectedEvent.endDate)}</p>
|
||||
<p className="text-brand-600">{fmtDate(selectedEvent.startDate)} — {fmtDate(selectedEvent.endDate)}</p>
|
||||
{selectedEvent.form?.isRequired && (
|
||||
<p className="text-orange-600 mt-1 text-xs font-medium">This event requires an attendee form to be completed at registration.</p>
|
||||
)}
|
||||
@@ -746,7 +746,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
onClick={handleStartKiosk}
|
||||
disabled={!selectedEventId}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-50 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-50 transition"
|
||||
>
|
||||
Start Kiosk
|
||||
</button>
|
||||
@@ -784,7 +784,7 @@ export default function SelfServicePage() {
|
||||
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
|
||||
}}
|
||||
onBlur={handleEmailBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -804,7 +804,7 @@ export default function SelfServicePage() {
|
||||
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
|
||||
}}
|
||||
onBlur={handlePhoneBlur}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="+27 82 000 0000"
|
||||
/>
|
||||
</div>
|
||||
@@ -831,7 +831,7 @@ export default function SelfServicePage() {
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="John Smith"
|
||||
required
|
||||
/>
|
||||
@@ -850,7 +850,7 @@ export default function SelfServicePage() {
|
||||
className={`flex-1 py-3 transition-colors ${
|
||||
notificationPref === p
|
||||
? p === "whatsapp" ? "bg-green-600 text-white"
|
||||
: p === "both" ? "bg-indigo-600 text-white"
|
||||
: p === "both" ? "bg-brand-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
@@ -901,7 +901,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
|
||||
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -932,7 +932,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
|
||||
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -959,7 +959,7 @@ export default function SelfServicePage() {
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
onClick={() => setCreateAccount((v) => !v)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-blue-600" : "bg-gray-300"}`}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
|
||||
</div>
|
||||
@@ -976,7 +976,7 @@ export default function SelfServicePage() {
|
||||
type={showVisitorPassword ? "text" : "password"}
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
placeholder="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
@@ -996,7 +996,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading || accountConflict}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
|
||||
</button>
|
||||
@@ -1033,7 +1033,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
|
||||
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
|
||||
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 transition"
|
||||
>
|
||||
Confirm & Update
|
||||
</button>
|
||||
@@ -1051,7 +1051,7 @@ export default function SelfServicePage() {
|
||||
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
|
||||
{mainTicketCount > 1 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-base font-semibold text-blue-700">
|
||||
<p className="text-base font-semibold text-brand-700">
|
||||
Attendee {currentTicketIndex + 1} of {mainTicketCount}
|
||||
</p>
|
||||
<div className="flex gap-1.5 justify-center mt-2">
|
||||
@@ -1062,7 +1062,7 @@ export default function SelfServicePage() {
|
||||
i < currentTicketIndex
|
||||
? "w-6 bg-green-500"
|
||||
: i === currentTicketIndex
|
||||
? "w-6 bg-blue-600"
|
||||
? "w-6 bg-brand-600"
|
||||
: "w-6 bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
@@ -1096,7 +1096,7 @@ export default function SelfServicePage() {
|
||||
checked={currentTicketAnswers[field.id] === opt}
|
||||
onChange={() => setCurrentTicketAnswers(a => ({ ...a, [field.id]: opt }))}
|
||||
required={field.isRequired && !currentTicketAnswers[field.id]}
|
||||
className="accent-blue-600"
|
||||
className="accent-brand-600"
|
||||
/>
|
||||
<span className="text-base">{opt}</span>
|
||||
</label>
|
||||
@@ -1108,7 +1108,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
) : field.type === "date" ? (
|
||||
<input
|
||||
@@ -1116,7 +1116,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
) : field.type === "paragraph" ? (
|
||||
<textarea
|
||||
@@ -1124,7 +1124,7 @@ export default function SelfServicePage() {
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -1132,7 +1132,7 @@ export default function SelfServicePage() {
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1147,7 +1147,7 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formSubmitLoading}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{formSubmitLoading
|
||||
? "Submitting…"
|
||||
@@ -1175,7 +1175,7 @@ export default function SelfServicePage() {
|
||||
<p className="text-lg text-gray-600 mb-3">Welcome, <span className="font-semibold">{confirmationName}</span>!</p>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
<p className="text-blue-700 font-medium mb-4">{selectedEvent.title}</p>
|
||||
<p className="text-brand-700 font-medium mb-4">{selectedEvent.title}</p>
|
||||
)}
|
||||
|
||||
{registrationIsFree ? (
|
||||
@@ -1189,7 +1189,7 @@ export default function SelfServicePage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-blue-800 text-sm mb-5 text-left">
|
||||
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-brand-800 text-sm mb-5 text-left">
|
||||
<p className="font-semibold mb-1">
|
||||
{notificationPref === "whatsapp"
|
||||
? "Payment instructions have been sent to your WhatsApp."
|
||||
@@ -1197,12 +1197,12 @@ export default function SelfServicePage() {
|
||||
? "Payment instructions have been sent via email and WhatsApp."
|
||||
: "Payment instructions have been emailed to you."}
|
||||
</p>
|
||||
<p className="text-blue-700">Payment can also be made at the door — cash and card accepted.</p>
|
||||
<p className="text-brand-700">Payment can also be made at the door — cash and card accepted.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-blue-200 bg-blue-50 text-2xl font-bold text-blue-700">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-brand-200 bg-brand-50 text-2xl font-bold text-brand-700">
|
||||
{countdown}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Returning to registration in {countdown}s</p>
|
||||
@@ -1210,7 +1210,7 @@ export default function SelfServicePage() {
|
||||
|
||||
<button
|
||||
onClick={resetAndGoToForm}
|
||||
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 transition"
|
||||
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 transition"
|
||||
>
|
||||
OK — Next Person
|
||||
</button>
|
||||
|
||||
@@ -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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { UserPlus, LayoutDashboard } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export const JoinUsButton = () => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return user ? (
|
||||
<Link href="/dashboard/user" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
My Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Join Us
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -4,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>
|
||||
<div className="flex flex-col h-full">
|
||||
{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="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>
|
||||
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm flex items-center justify-between shrink-0">
|
||||
{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 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
<h2 className="text-lg font-semibold">QR Scanner</h2>
|
||||
<button
|
||||
onClick={() => (isOn ? stopScan() : startScan())}
|
||||
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-blue-600"}`}
|
||||
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-brand-600"}`}
|
||||
>
|
||||
{isOn ? "Stop" : "Scan Ticket"}
|
||||
</button>
|
||||
|
||||
@@ -22,11 +22,12 @@ export default function ReportViewerModal({
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="absolute inset-0 flex items-start justify-center p-4 overflow-auto">
|
||||
<div className="w-full max-w-6xl bg-white rounded-xl shadow-xl my-8" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b">
|
||||
<div className="border-b">
|
||||
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-indigo-600" />
|
||||
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
@@ -34,24 +35,33 @@ export default function ReportViewerModal({
|
||||
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="hidden sm:flex items-center gap-2 flex-wrap shrink-0 ml-auto">
|
||||
{actions}
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="sm:hidden p-2 rounded-lg hover:bg-gray-100 shrink-0" onClick={onClose} aria-label="Close">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex sm:hidden items-center gap-2 flex-wrap px-5 pb-4">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
|
||||
<div className="flex items-center gap-2 text-sm text-indigo-900 flex-1 min-w-0">
|
||||
<Info className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-brand-50/50">
|
||||
<div className="flex items-center gap-2 text-sm text-brand-900 flex-1 min-w-0">
|
||||
<Info className="w-4 h-4 text-brand-400 shrink-0" />
|
||||
{filters || <span>This report has no extra filters beyond Events and Date range in the sidebar.</span>}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={busy}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
X, Home, Filter, ListFilter, Download, BarChart2, MessageCircleQuestion,
|
||||
Calendar, CalendarClock, EyeOff, Printer, Mail, FileSpreadsheet, MessageCircle,
|
||||
CreditCard, Gift, Clock, HandHeart, RefreshCw, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type GuideTab = "overview" | "universal" | "specific" | "exporting" | "fields" | "help";
|
||||
|
||||
const TABS: { key: GuideTab; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "overview", label: "Overview", icon: Home },
|
||||
{ key: "universal", label: "Universal filters", icon: Filter },
|
||||
{ key: "specific", label: "Report-specific filters", icon: ListFilter },
|
||||
{ key: "exporting", label: "Exporting reports", icon: Download },
|
||||
{ key: "fields", label: "Fields & metrics", icon: BarChart2 },
|
||||
{ key: "help", label: "Need more help?", icon: MessageCircleQuestion },
|
||||
];
|
||||
|
||||
const TONES = {
|
||||
indigo: { bg: "bg-indigo-50", icon: "text-indigo-600" },
|
||||
emerald: { bg: "bg-emerald-50", icon: "text-emerald-600" },
|
||||
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
|
||||
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
|
||||
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
|
||||
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
|
||||
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
|
||||
} as const;
|
||||
type Tone = keyof typeof TONES;
|
||||
|
||||
function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
|
||||
const t = TONES[tone] || TONES.gray;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
|
||||
<Icon className={"w-4 h-4 " + t.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GUIDE_DISMISSED_KEY = "hope_events_reports_guide_dismissed";
|
||||
const ADMIN_EMAIL = "admin@crosscode.co.za";
|
||||
|
||||
export default function ReportingGuideModal({ onClose }: { onClose: (dontShowAgain: boolean) => void }) {
|
||||
const [tab, setTab] = useState<GuideTab>("overview");
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
return (
|
||||
// Above the site header (Navbar is `sticky top-0 z-50`) and above the report popup
|
||||
// (z-[60]), since the guide can be opened while a report is showing.
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => onClose(dontShowAgain)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-3xl bg-white rounded-xl shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircleQuestion className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Reporting guide</h2>
|
||||
<p className="text-xs text-gray-500">This guide explains how reports work and how to use the available filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-1.5 rounded hover:bg-gray-100" onClick={() => onClose(dontShowAgain)} aria-label="Close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg " + (active ? "bg-indigo-50 text-indigo-700 font-medium" : "text-gray-600 hover:bg-gray-50")}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
|
||||
{tab === "overview" && (
|
||||
<div className="space-y-4">
|
||||
<p>Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Filter} title="Use filters" tone="indigo">
|
||||
Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Preview & customize" tone="emerald">
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "universal" && (
|
||||
<div className="space-y-4">
|
||||
<p>Universal filters live in the sidebar on the left and apply to whichever report you open — you only set them once, not per report.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Calendar} title="Events" tone="indigo">
|
||||
Pick one or more events. Every report loads data for exactly these events.
|
||||
</GuideItem>
|
||||
<GuideItem icon={EyeOff} title="Include past / inactive / closed events" tone="gray">
|
||||
Controls which events even appear in the Events list to pick from.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarClock} title="Date range" tone="blue">
|
||||
A preset (This month, Last month, This year) or a custom range. Only applies to reports that are inherently date-based (e.g. Payments between dates, Revenue reports, Cashup audit trail) — reports like Attendees or Ticket usage show a live snapshot and ignore the date range.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "specific" && (
|
||||
<div className="space-y-4">
|
||||
<p>Some reports have extra options that only make sense for that report — these appear at the top of the report popup once it's open, separate from the universal filters.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ListFilter} title="Attendees" tone="violet">
|
||||
Which single event to show (defaults to the first selected event) and whether to include cancelled registrations.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Registration status breakdown" tone="emerald">
|
||||
Whether to include cancelled registrations in the counts, and whether to count by number of registrations or by ticket quantity (so someone with 3 tickets counts as 3).
|
||||
</GuideItem>
|
||||
<GuideItem icon={RefreshCw} title="Refresh" tone="indigo">
|
||||
Adjust a report-specific filter, then use the "Refresh" button inside the popup to re-run the report without closing it.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "exporting" && (
|
||||
<div className="space-y-4">
|
||||
<p>Every report can be exported straight from its popup:</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Printer} title="Print" tone="gray">
|
||||
Opens a print-ready PDF in a new tab; use your browser's print button from there.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Mail} title="Email" tone="blue">
|
||||
Sends the PDF to your own account email.
|
||||
</GuideItem>
|
||||
<GuideItem icon={FileSpreadsheet} title="Excel" tone="emerald">
|
||||
Downloads a styled .xlsx workbook — colored header, key totals, and a chart section where available — matching the on-screen report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageCircle} title="WhatsApp" tone="violet">
|
||||
Sends the PDF to your own account's WhatsApp number (needs a valid phone number on file).
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-4">
|
||||
<p>A few terms come up across several financial reports and are easy to misread — here's what each one actually means:</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={CreditCard} title="Paid" tone="blue">
|
||||
Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else's donation.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Gift} title="Paid via donation" tone="violet">
|
||||
The portion of an order that was covered by an assigned donation. This is part of what's "settled" on the order, but it's the donor's money, not the registrant's — so it's broken out separately and attributed to the donor elsewhere in the report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Outstanding" tone="amber">
|
||||
What's still owed on an order, after direct payments and any donation cover.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Unassigned donations" tone="rose">
|
||||
Real money already received as a donation that hasn't been applied to any order yet.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Donations: Used / Unused" tone="emerald">
|
||||
How much of a given donation has been assigned to orders (Used) versus what's still available to assign (Unused). A donation is never overwritten when assigned — the original donation record always keeps its full original amount.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "help" && (
|
||||
<div className="space-y-4">
|
||||
<p>Still stuck? Reach out to the site administrator — they can check the underlying data with you or flag anything that looks wrong.</p>
|
||||
<div className="flex items-start gap-3 border border-gray-100 rounded-xl p-4 bg-gray-50">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">Site administrator</div>
|
||||
<a href={`mailto:${ADMIN_EMAIL}`} className="text-sm text-indigo-600 hover:underline">{ADMIN_EMAIL}</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Financial figures matter — if a number in a report doesn't look right, it's always worth asking rather than assuming.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
|
||||
Don't show this again
|
||||
</label>
|
||||
<button className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => onClose(dontShowAgain)}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { downloadCsv, mailtoReport, openPrintWindow } from "@/lib/export";
|
||||
|
||||
// Common small UI controls
|
||||
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-lg font-semibold">{title}</div>
|
||||
<div className="flex gap-2">{actions}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelect({ options, value, onChange, className }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void; className?: string }) {
|
||||
const toggle = (v: string) => {
|
||||
const set = new Set(value);
|
||||
if (set.has(v)) set.delete(v); else set.add(v);
|
||||
onChange(Array.from(set));
|
||||
};
|
||||
return (
|
||||
<div className={"flex flex-wrap gap-2 " + (className || '')}>
|
||||
{options.map(opt => (
|
||||
<label key={opt.value} className={"px-2 py-1 text-xs rounded border cursor-pointer " + (value.includes(opt.value) ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-800 border-gray-200") }>
|
||||
<input type="checkbox" className="hidden" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Reports() {
|
||||
const { token, user } = useAuth();
|
||||
const role = user?.role || 'user';
|
||||
const canView = role === 'admin' || role === 'supervisor';
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
setEvents(Array.isArray(evs) ? evs : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load events');
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
})();
|
||||
}, [token, role]);
|
||||
|
||||
// Filters state
|
||||
const [dateFrom, setDateFrom] = useState<string>("");
|
||||
const [dateTo, setDateTo] = useState<string>("");
|
||||
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
|
||||
const [attendeesEventId, setAttendeesEventId] = useState<string>("");
|
||||
const [includeCancelled, setIncludeCancelled] = useState<boolean>(false);
|
||||
const [includePastEvents, setIncludePastEvents] = useState<boolean>(false);
|
||||
|
||||
// DATA
|
||||
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
|
||||
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load initial selected events
|
||||
useEffect(() => {
|
||||
if (events.length > 0 && selectedEvents.length === 0) {
|
||||
const nowIso = new Date().toISOString();
|
||||
const filtered = events.filter(ev => includePastEvents || !ev.endDate || new Date(ev.endDate).toISOString() >= nowIso);
|
||||
const ids = filtered.map(ev => ev.id);
|
||||
setSelectedEvents(ids.slice(0, Math.min(ids.length, 3))); // pick first few by default
|
||||
if (!attendeesEventId && ids.length > 0) setAttendeesEventId(ids[0]);
|
||||
}
|
||||
}, [events, includePastEvents, attendeesEventId]);
|
||||
|
||||
// Fetch payments per selected event (works for staff via /event/:id, and for supervisor/admin we could also use this)
|
||||
const loadPayments = async (eventIds: string[]) => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const byEv: Record<string, any[]> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
byEv[id] = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
byEv[id] = [];
|
||||
}
|
||||
}
|
||||
setPaymentsByEvent(byEv);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch registrations for selected/attendees events
|
||||
const loadRegistrations = async (eventIds: string[]) => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const byEv: Record<string, any[]> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
byEv[id] = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
byEv[id] = [];
|
||||
}
|
||||
}
|
||||
setRegistrationsByEvent(byEv);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEvents.length > 0) {
|
||||
loadPayments(selectedEvents);
|
||||
loadRegistrations(selectedEvents);
|
||||
}
|
||||
}, [token, selectedEvents]);
|
||||
|
||||
// Helpers
|
||||
const paymentsRows = useMemo(() => {
|
||||
// Flatten to rows applying date filter, grouped by event then user in presentation
|
||||
const df = dateFrom ? new Date(dateFrom).getTime() : null;
|
||||
const dt = dateTo ? new Date(dateTo).getTime() : null;
|
||||
const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; registrationId?: string }[] = [];
|
||||
for (const evId of Object.keys(paymentsByEvent)) {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const evTitle = ev?.title || evId;
|
||||
for (const p of paymentsByEvent[evId] || []) {
|
||||
const t = new Date(p.createdAt).getTime();
|
||||
if ((df && t < df) || (dt && t > dt)) continue;
|
||||
// For reporting: if it's a donation, show the payer; otherwise show the user assigned to the registration
|
||||
const user = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {});
|
||||
rows.push({
|
||||
eventId: evId,
|
||||
eventTitle: evTitle,
|
||||
userName: user?.name || user?.email || p.userId || 'User',
|
||||
userEmail: user?.email,
|
||||
amount: p.amount,
|
||||
method: p.method,
|
||||
isDonation: p.isDonation,
|
||||
createdAt: p.createdAt,
|
||||
registrationId: p.registrationId || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort by event, then user, then date
|
||||
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '') || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
|
||||
return rows;
|
||||
}, [paymentsByEvent, dateFrom, dateTo, events]);
|
||||
|
||||
const attendeesRows = useMemo(() => {
|
||||
const evId = attendeesEventId;
|
||||
const regs = registrationsByEvent[evId] || [];
|
||||
const filtered = regs.filter((r: any) => includeCancelled ? true : (r.status !== 'cancelled'));
|
||||
const rows = filtered.map((r: any) => ({
|
||||
name: r.user?.name || r.userId,
|
||||
email: r.user?.email,
|
||||
status: r.status,
|
||||
options: (r.registrationOptions || []).map((ro: any) => `${ro.eventOption?.name || 'Option'} x${ro.quantity}`).join('; '),
|
||||
registeredAt: r.createdAt,
|
||||
}));
|
||||
rows.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
|
||||
return rows;
|
||||
}, [registrationsByEvent, attendeesEventId, includeCancelled]);
|
||||
|
||||
const regTypeCounts = useMemo(() => {
|
||||
// For selectedEvents gather counts of eventOption.name quantities
|
||||
const counts: Record<string, Record<string, number>> = {}; // eventId -> optionName -> qty
|
||||
for (const evId of selectedEvents) {
|
||||
const regs = registrationsByEvent[evId] || [];
|
||||
counts[evId] = counts[evId] || {};
|
||||
for (const r of regs) {
|
||||
if (r.status === 'cancelled') continue; // default exclude
|
||||
for (const ro of (r.registrationOptions || [])) {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 0;
|
||||
counts[evId][name] = (counts[evId][name] || 0) + qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [registrationsByEvent, selectedEvents]);
|
||||
|
||||
// Actions for each report
|
||||
const exportPaymentsCsv = () => {
|
||||
downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentsRows.map(r => ({
|
||||
Event: r.eventTitle,
|
||||
User: r.userName,
|
||||
Email: r.userEmail || '',
|
||||
Amount: r.amount,
|
||||
Method: r.method,
|
||||
Donation: r.isDonation ? 'Yes' : 'No',
|
||||
Date: new Date(r.createdAt).toLocaleString()
|
||||
})));
|
||||
};
|
||||
const printPayments = () => {
|
||||
const html = tableHtml(['Event','User','Email','Amount','Method','Donation','Date'], paymentsRows.map(r => [
|
||||
r.eventTitle,
|
||||
r.userName,
|
||||
r.userEmail || '',
|
||||
String(r.amount),
|
||||
r.method,
|
||||
r.isDonation ? 'Yes' : 'No',
|
||||
new Date(r.createdAt).toLocaleString()
|
||||
]));
|
||||
openPrintWindow('Payments Report', html);
|
||||
};
|
||||
const emailPayments = () => {
|
||||
const summary = `Payments report generated on ${new Date().toLocaleString()}\nFilters: from ${dateFrom || '-'} to ${dateTo || '-'}; Events: ${selectedEvents.length}`;
|
||||
mailtoReport('Payments Report', summary + '\n\nPlease find the CSV/PDF attached.');
|
||||
};
|
||||
|
||||
const exportAttendeesCsv = () => {
|
||||
downloadCsv(`attendees_${attendeesEventId}_${new Date().toISOString().slice(0,10)}`, attendeesRows.map(r => ({
|
||||
Name: r.name,
|
||||
Email: r.email || '',
|
||||
Status: r.status,
|
||||
Options: r.options,
|
||||
RegisteredAt: new Date(r.registeredAt).toLocaleString()
|
||||
})));
|
||||
};
|
||||
const printAttendees = () => {
|
||||
const html = tableHtml(['Name','Email','Status','Options','Registered At'], attendeesRows.map(r => [r.name, r.email || '', r.status, r.options, new Date(r.registeredAt).toLocaleString()]));
|
||||
openPrintWindow('Attendees Report', html);
|
||||
};
|
||||
const emailAttendees = () => {
|
||||
const ev = events.find(e => e.id === attendeesEventId);
|
||||
const summary = `Attendees for ${ev?.title || attendeesEventId} generated on ${new Date().toLocaleString()}\nInclude cancelled: ${includeCancelled ? 'Yes' : 'No'}`;
|
||||
mailtoReport('Attendees Report', summary + '\n\nPlease find the CSV/PDF attached.');
|
||||
};
|
||||
|
||||
const exportRegTypesCsv = () => {
|
||||
const rows: any[] = [];
|
||||
for (const evId of Object.keys(regTypeCounts)) {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const byType = regTypeCounts[evId];
|
||||
for (const type of Object.keys(byType)) rows.push({ Event: ev?.title || evId, Type: type, Quantity: byType[type] });
|
||||
}
|
||||
downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, rows);
|
||||
};
|
||||
const printRegTypes = () => {
|
||||
const rows: string[][] = [];
|
||||
for (const evId of Object.keys(regTypeCounts)) {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const byType = regTypeCounts[evId];
|
||||
for (const type of Object.keys(byType)) rows.push([ev?.title || evId, type, String(byType[type])]);
|
||||
}
|
||||
const html = tableHtml(['Event','Type','Quantity'], rows);
|
||||
openPrintWindow('Registration Types Report', html);
|
||||
};
|
||||
const emailRegTypes = () => {
|
||||
const summary = `Registration types report generated on ${new Date().toLocaleString()} for ${Object.keys(regTypeCounts).length} event(s).`;
|
||||
mailtoReport('Registration Types Report', summary + '\n\nPlease find the CSV/PDF attached.');
|
||||
};
|
||||
|
||||
// Extra useful report: Ticket usage summary per event (Used vs Unused, requires ticket scans info already available via tickets API on staff pages)
|
||||
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
|
||||
const loadTicketUsage = async (eventIds: string[]) => {
|
||||
if (!token) return;
|
||||
const result: Record<string, { used: number; unused: number }> = {};
|
||||
for (const id of eventIds) {
|
||||
try {
|
||||
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
|
||||
const used = tickets.filter(t => t.isUsed).length;
|
||||
const unused = tickets.filter(t => !t.isUsed).length;
|
||||
result[id] = { used, unused };
|
||||
} catch (e) {
|
||||
result[id] = { used: 0, unused: 0 };
|
||||
}
|
||||
}
|
||||
setTicketUsage(result);
|
||||
};
|
||||
useEffect(() => { if (selectedEvents.length) loadTicketUsage(selectedEvents); }, [token, selectedEvents]);
|
||||
|
||||
const exportUsageCsv = () => {
|
||||
const rows: any[] = [];
|
||||
for (const evId of Object.keys(ticketUsage)) {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused });
|
||||
}
|
||||
downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows);
|
||||
};
|
||||
const printUsage = () => {
|
||||
const rows: string[][] = [];
|
||||
for (const evId of Object.keys(ticketUsage)) {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const tu = ticketUsage[evId];
|
||||
rows.push([ev?.title || evId, String(tu.used), String(tu.unused)]);
|
||||
}
|
||||
const html = tableHtml(['Event','Used','Unused'], rows);
|
||||
openPrintWindow('Ticket Usage Report', html);
|
||||
};
|
||||
const emailUsage = () => {
|
||||
const summary = `Ticket usage report generated on ${new Date().toLocaleString()} for ${Object.keys(ticketUsage).length} event(s).`;
|
||||
mailtoReport('Ticket Usage Report', summary + '\n\nPlease find the CSV/PDF attached.');
|
||||
};
|
||||
|
||||
// UI
|
||||
return (
|
||||
<div>
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need supervisor or admin access to view reports.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-sm font-medium mb-2">Global filters</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-64">
|
||||
<label className="block text-xs text-gray-600 mb-1">Select events</label>
|
||||
<MultiSelect
|
||||
options={events.map(ev => ({ value: ev.id, label: ev.title }))}
|
||||
value={selectedEvents}
|
||||
onChange={setSelectedEvents}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} /> Include past events in list
|
||||
</label>
|
||||
<button onClick={() => { loadPayments(selectedEvents); loadRegistrations(selectedEvents); }} className="px-3 py-2 text-sm rounded bg-gray-100 hover:bg-gray-200">Refresh data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section
|
||||
title="Payments between dates (grouped by event then user)"
|
||||
actions={
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportPaymentsCsv}>Export CSV</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={printPayments}>Save as PDF</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailPayments}>Email</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading && <div className="text-sm text-gray-500 mb-2">Loading…</div>}
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-[640px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Donation</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paymentsRows.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-sm text-gray-500">No payments match the selected filters.</td></tr>
|
||||
) : paymentsRows.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{r.eventTitle}</td>
|
||||
<td>{r.userName}</td>
|
||||
<td>{r.userEmail || ''}</td>
|
||||
<td>R {Number(r.amount).toFixed(2)}</td>
|
||||
<td>{r.method}</td>
|
||||
<td>{r.isDonation ? 'Yes' : 'No'}</td>
|
||||
<td>{new Date(r.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Attendees per event and registration status"
|
||||
actions={
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportAttendeesCsv}>Export CSV</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={printAttendees}>Save as PDF</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailAttendees}>Email</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<label className="text-sm">
|
||||
<span className="text-gray-600 mr-2">Event</span>
|
||||
<select className="border rounded px-3 py-2 text-sm" value={attendeesEventId} onChange={e => setAttendeesEventId(e.target.value)}>
|
||||
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={includeCancelled} onChange={e => setIncludeCancelled(e.target.checked)} /> Include cancelled registrations
|
||||
</label>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={() => loadRegistrations([attendeesEventId])}>Refresh</button>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-[640px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Options</th>
|
||||
<th>Registered at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attendeesRows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-sm text-gray-500">No attendees for selected filters.</td></tr>
|
||||
) : attendeesRows.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.email || ''}</td>
|
||||
<td className="capitalize">{r.status}</td>
|
||||
<td>{r.options}</td>
|
||||
<td>{new Date(r.registeredAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Registration type counts per event"
|
||||
actions={
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportRegTypesCsv}>Export CSV</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={printRegTypes}>Save as PDF</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailRegTypes}>Email</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-[480px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Registration Type</th>
|
||||
<th>Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(regTypeCounts).length === 0 ? (
|
||||
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
|
||||
) : (
|
||||
Object.keys(regTypeCounts).flatMap(evId => {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const byType = regTypeCounts[evId] || {};
|
||||
const rows = Object.keys(byType);
|
||||
if (rows.length === 0) return [<tr key={evId}><td>{ev?.title || evId}</td><td colSpan={2} className="text-sm text-gray-500">No registrations</td></tr>];
|
||||
return rows.map(type => (
|
||||
<tr key={evId + type}>
|
||||
<td>{ev?.title || evId}</td>
|
||||
<td>{type}</td>
|
||||
<td>{byType[type]}</td>
|
||||
</tr>
|
||||
));
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Ticket usage summary (extra)"
|
||||
actions={
|
||||
<>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportUsageCsv}>Export CSV</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={printUsage}>Save as PDF</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailUsage}>Email</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-[420px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Used</th>
|
||||
<th>Unused</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(ticketUsage).length === 0 ? (
|
||||
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
|
||||
) : Object.keys(ticketUsage).map(evId => {
|
||||
const ev = events.find(e => e.id === evId);
|
||||
const tu = ticketUsage[evId];
|
||||
return (
|
||||
<tr key={evId}>
|
||||
<td>{ev?.title || evId}</td>
|
||||
<td>{tu.used}</td>
|
||||
<td>{tu.unused}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tableHtml(headers: string[], rows: (string | number)[][]) {
|
||||
const thead = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join('')}</tr>`;
|
||||
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ''))}</td>`).join('')}</tr>`).join('');
|
||||
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
|
||||
import EventsDropdown from "./EventsDropdown";
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function ReportsShell({
|
||||
dateFrom, setDateFrom, dateTo, setDateTo,
|
||||
report, setReport,
|
||||
onViewReport, busy,
|
||||
onOpenGuide,
|
||||
onBack,
|
||||
}: {
|
||||
events: EventLite[]; isAdmin: boolean;
|
||||
@@ -28,7 +27,6 @@ export default function ReportsShell({
|
||||
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
|
||||
report: ReportKey; setReport: (r: ReportKey) => void;
|
||||
onViewReport: () => void; busy: boolean;
|
||||
onOpenGuide: () => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -67,23 +65,23 @@ export default function ReportsShell({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 min-w-0 sm:flex-none">
|
||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
className="w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
|
||||
className="w-full sm:w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
|
||||
placeholder="Search reports…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button type="button" className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
||||
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
)}
|
||||
@@ -128,21 +126,19 @@ export default function ReportsShell({
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
{datePreset === "custom" && (
|
||||
<div className="flex gap-2">
|
||||
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-0.5">From</div>
|
||||
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-0.5">To</div>
|
||||
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
|
||||
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
|
||||
<span>
|
||||
<span className="block font-medium text-gray-800">Need help?</span>
|
||||
<span className="block text-xs text-gray-500">View our reporting guide</span>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Main: categories, report grid */}
|
||||
@@ -152,7 +148,7 @@ export default function ReportsShell({
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
|
||||
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
@@ -169,7 +165,7 @@ export default function ReportsShell({
|
||||
key={r.key}
|
||||
type="button"
|
||||
onClick={() => setReport(r.key)}
|
||||
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
|
||||
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-brand-500 ring-2 ring-brand-100 bg-brand-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
@@ -193,7 +189,7 @@ export default function ReportsShell({
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={onViewReport}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Loading…" : "View report"}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
@@ -8,7 +9,6 @@ import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
import ReportingGuideModal, { GUIDE_DISMISSED_KEY } from "./ReportingGuideModal";
|
||||
import { StatTile, StatTileRow } from "./StatTile";
|
||||
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const { token, user } = useAuth();
|
||||
const role = user?.role || "user";
|
||||
const canView = role === "admin" || role === "supervisor";
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Events list
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
@@ -135,23 +136,6 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const [ready, setReady] = useState(false); // toggled by View button
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
|
||||
// Show the reporting guide automatically on first visit, unless the user dismissed it for good.
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage.getItem(GUIDE_DISMISSED_KEY) !== "1") {
|
||||
setGuideOpen(true);
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const closeGuide = (dontShowAgain: boolean) => {
|
||||
setGuideOpen(false);
|
||||
if (dontShowAgain) {
|
||||
try { window.localStorage.setItem(GUIDE_DISMISSED_KEY, "1"); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Universal filters — apply to every report's onView loader (per-report filters below stay
|
||||
// report-specific). A single events selection replaces what used to be ~10 separate
|
||||
@@ -190,10 +174,48 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
|
||||
const [auditRows, setAuditRows] = useState<any[]>([]);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
// trailing month (today back one month, matching the dashboard KPI window — see
|
||||
// trailingMonthRanges in backend/src/controllers/statsController.js), instead of landing
|
||||
// on the plain report grid. deepLinkHandledRef is a ref (not state) so it updates
|
||||
// synchronously — the "Initialize default selection" effect right below reads it in the
|
||||
// same commit to skip its own single-event default.
|
||||
const deepLinkHandledRef = useRef(false);
|
||||
const [autoViewPending, setAutoViewPending] = useState(false);
|
||||
useEffect(() => {
|
||||
if (deepLinkHandledRef.current) return;
|
||||
if (filteredEvents.length === 0) return;
|
||||
const reportParam = searchParams.get("report");
|
||||
if (!reportParam || !REPORTS.some(r => r.key === reportParam)) return;
|
||||
deepLinkHandledRef.current = true;
|
||||
setReport(reportParam as ReportKey);
|
||||
setSelectedEventIds(filteredEvents.map((e: any) => e.id));
|
||||
if (searchParams.get("range") === "trailing_month") {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
setDateFrom(iso(start));
|
||||
setDateTo(iso(now));
|
||||
}
|
||||
setPopupOpen(true);
|
||||
setAutoViewPending(true);
|
||||
}, [filteredEvents, searchParams]);
|
||||
|
||||
// Fires once the state set above has actually committed (selectedEventIds reflects the
|
||||
// deep-link's full event list), so onView() below closes over the updated values instead
|
||||
// of the stale defaults from the render that scheduled it.
|
||||
useEffect(() => {
|
||||
if (!autoViewPending || selectedEventIds.length === 0) return;
|
||||
setAutoViewPending(false);
|
||||
onView();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoViewPending, selectedEventIds, report]);
|
||||
|
||||
// Initialize default selection when events load
|
||||
useEffect(() => {
|
||||
if (filteredEvents.length === 0) return;
|
||||
if (selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
|
||||
if (!deepLinkHandledRef.current && selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
|
||||
if (!attEventId) setAttEventId(filteredEvents[0].id);
|
||||
}, [filteredEvents]);
|
||||
|
||||
@@ -336,12 +358,17 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Payment method label mapping
|
||||
// Payment method label mapping — bucket into the same 4 categories used by the finance/cashup
|
||||
// reports (cash, card, eft, other). Card-network wallets (Apple Pay, Google Pay) and Yoco
|
||||
// checkout-portal payments settle exactly like a card — no separate float to reconcile — so
|
||||
// they fold into "Card" rather than showing as their own categories. Mirrors bucketForMethod
|
||||
// in backend/src/utils/cashupUtils.js.
|
||||
const getPaymentMethod = (p: any) => {
|
||||
try {
|
||||
if (p?.externalId) return "Yoco Portal";
|
||||
} catch {}
|
||||
return p?.method || "Unknown";
|
||||
const m = String(p?.method || "").toLowerCase();
|
||||
if (m.includes("cash")) return "Cash";
|
||||
if (m.includes("eft")) return "EFT";
|
||||
if (m.includes("card") || m.includes("yoco") || m.includes("pay") || p?.externalId) return "Card";
|
||||
return "Other";
|
||||
};
|
||||
|
||||
// Derived rows for payments (apply date filter)
|
||||
@@ -474,9 +501,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const evTitle = ev?.title || evId;
|
||||
const evPayments = paymentsByEvent[evId] || [];
|
||||
evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => {
|
||||
// A refund of the donation itself also creates a leg (originalPaymentId -> donation),
|
||||
// with a negative amount — Math.abs() so it reduces "used" (money no longer available)
|
||||
// instead of a raw signed sum subtracting a negative and inflating "unused" below.
|
||||
const used = evPayments
|
||||
.filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id)
|
||||
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
|
||||
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
|
||||
rows.push({
|
||||
eventId: evId,
|
||||
eventTitle: evTitle,
|
||||
@@ -858,9 +888,11 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
};
|
||||
}
|
||||
if (report === "donations") {
|
||||
// Math.abs(): a refund of the donation itself also creates a leg (originalPaymentId ->
|
||||
// donation) with a negative amount, which must reduce "used" rather than inflate "unused".
|
||||
const usedFor = (evId: string, donationId: string) =>
|
||||
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
|
||||
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
|
||||
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
|
||||
const rows: (string|number)[][] = [];
|
||||
let grandTotal = 0, grandUsed = 0;
|
||||
Object.keys(paymentsByEvent).forEach(evId => {
|
||||
@@ -1203,12 +1235,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
report={report} setReport={(r: ReportKey) => { setReport(r); setReady(false); }}
|
||||
onViewReport={() => { setPopupOpen(true); onView(); }}
|
||||
busy={busy}
|
||||
onOpenGuide={() => setGuideOpen(true)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
{guideOpen && <ReportingGuideModal onClose={closeGuide} />}
|
||||
|
||||
{popupOpen && (
|
||||
<ReportViewerModal
|
||||
title={activeReportMeta?.label || "Report"}
|
||||
@@ -1531,10 +1560,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
// sum of every leg (isDonation:false, originalPaymentId -> the donation)
|
||||
// referencing it, which — since a donation and its legs share the event
|
||||
// it was logged against in the common case — are already present in the
|
||||
// same per-event payments list.
|
||||
// same per-event payments list. Math.abs(): a refund of the donation itself
|
||||
// also creates such a leg, with a negative amount, which must reduce "used"
|
||||
// rather than inflate "unused" via a raw signed sum.
|
||||
const usedFor = (evId: string, donationId: string) =>
|
||||
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
|
||||
.reduce((s: number, leg: any) => s + (leg.amount || 0), 0);
|
||||
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
|
||||
let grandTotal = 0, grandUsed = 0, grandCount = 0;
|
||||
const perEvent = Object.keys(paymentsByEvent).map(evId => {
|
||||
const ev = filteredEvents.find(e => e.id === evId);
|
||||
@@ -1685,7 +1716,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<td className="py-2 px-3 text-right">R {masterTotals?.paidViaDonation.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {masterTotals?.outstanding.toFixed(2)}</td>
|
||||
</tr>
|
||||
<tr className="text-xs bg-blue-50/60">
|
||||
<tr className="text-xs bg-brand-50/60">
|
||||
<td className="py-1.5 px-3" colSpan={3}>Revenue per Ticket</td>
|
||||
|
||||
{masterOptions.map(opt => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "outline" | "danger";
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading = false,
|
||||
className = "",
|
||||
children,
|
||||
disabled,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const base = "inline-flex items-center justify-center rounded px-4 py-2 text-sm transition-colors";
|
||||
const variants: Record<string, string> = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-800 text-white hover:bg-black/90",
|
||||
outline: "border border-gray-300 text-gray-800 hover:bg-gray-50",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
};
|
||||
const cls = [base, variants[variant], disabled || loading ? "opacity-60 cursor-not-allowed" : "", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<button className={cls} disabled={disabled || loading} {...rest}>
|
||||
{loading && (
|
||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
|
||||
export function ContactSection() {
|
||||
const { settings } = useSiteSettings();
|
||||
|
||||
const email = settings.org_email || "";
|
||||
const phone = settings.org_phone || "";
|
||||
const address = settings.org_address || "";
|
||||
|
||||
if (!email && !phone && !address) return null;
|
||||
|
||||
return (
|
||||
<section id="contact" className="bg-gray-100 py-12 px-4 text-center">
|
||||
<SectionHeader title="Contact Us" />
|
||||
{email && (
|
||||
<p className="text-gray-700 mb-2">
|
||||
📧{" "}
|
||||
<a href={`mailto:${email}`} className="hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{phone && (
|
||||
<p className="text-gray-700 mb-2">
|
||||
📞{" "}
|
||||
<a href={`tel:${phone}`} className="hover:underline">
|
||||
{phone}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{address && (
|
||||
<p className="text-gray-700">
|
||||
📍 {address}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { TONES, type Tone } from "./tones";
|
||||
|
||||
/** A labeled icon + description row, used inside help-guide tab content. */
|
||||
export function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
|
||||
const t = TONES[tone] || TONES.gray;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
|
||||
<Icon className={"w-4 h-4 " + t.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useHelpGuide } from "@/hooks/useHelpGuide";
|
||||
import { resolveHelpContent } from "@/content/help/registry";
|
||||
import HelpGuideModal from "./HelpGuideModal";
|
||||
|
||||
/**
|
||||
* The site's single help entry point — a floating round button, present on
|
||||
* every page (mounted once in the root layout). Content is resolved from
|
||||
* the current route via content/help/registry.ts, falling back to general
|
||||
* baseline content (how to register / view tickets) for any page without a
|
||||
* dedicated guide yet.
|
||||
*/
|
||||
// Unattended/operator-driven screens where a floating help button would just
|
||||
// get in the way (kiosk touchscreen, first-run setup wizard) — this hook
|
||||
// still runs, it just renders nothing there.
|
||||
const EXCLUDED_PREFIXES = ["/self-service", "/setup"];
|
||||
|
||||
export default function HelpFab() {
|
||||
const pathname = usePathname() || "/";
|
||||
const { content } = resolveHelpContent(pathname);
|
||||
const { open, openGuide, closeGuide } = useHelpGuide();
|
||||
|
||||
if (EXCLUDED_PREFIXES.some(prefix => pathname.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openGuide}
|
||||
aria-label="Help"
|
||||
// bottom-20 on mobile clears BottomNav (fixed, h-16 + safe-area, z-50).
|
||||
className="fixed z-40 bottom-20 md:bottom-6 right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
|
||||
>
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
</button>
|
||||
{open && <HelpGuideModal content={content} onClose={closeGuide} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { X, Mail, MessageCircleQuestion } from "lucide-react";
|
||||
import type { HelpContent } from "@/content/help/types";
|
||||
|
||||
export default function HelpGuideModal({ content, onClose }: { content: HelpContent; onClose: () => void }) {
|
||||
const [tab, setTab] = useState(content.tabs[0]?.key);
|
||||
const activeTab = content.tabs.find(t => t.key === tab) || content.tabs[0];
|
||||
|
||||
return (
|
||||
// Above the site header (Navbar is `sticky top-0 z-50`) and above any
|
||||
// page-level popup (e.g. the report viewer at z-[60]), since the guide
|
||||
// can be opened from the FAB while another overlay is showing.
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-200" onClick={onClose} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-full bg-white rounded-2xl shadow-2xl animate-in fade-in zoom-in-95 slide-in-from-bottom-2 duration-200 overflow-hidden flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b bg-gradient-to-r from-brand-50/60 to-white shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircleQuestion className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">{content.title}</h2>
|
||||
<p className="text-xs text-gray-500">{content.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-1.5 rounded-full hover:bg-gray-100 transition-colors" onClick={onClose} aria-label="Close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{content.quickLinks && content.quickLinks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-5 py-3 border-b bg-gray-50 shrink-0">
|
||||
{content.quickLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full border border-brand-200 bg-white text-brand-700 hover:bg-brand-100 hover:border-brand-300 transition-colors"
|
||||
>
|
||||
{link.icon && <link.icon className="w-3.5 h-3.5" />}
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row flex-1 min-h-0 overflow-y-auto sm:overflow-visible">
|
||||
{content.tabs.length > 1 && (
|
||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1 bg-gray-50/50 sm:overflow-y-auto">
|
||||
{content.tabs.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = activeTab?.key === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg transition-colors " + (active ? "bg-brand-600 text-white font-medium shadow-sm" : "text-gray-600 hover:bg-white hover:shadow-sm")}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 sm:overflow-y-auto">
|
||||
{activeTab?.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-3 space-y-2 bg-gray-50/50 shrink-0">
|
||||
{content.supportContact && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Mail className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
{content.supportContact.label}:{" "}
|
||||
<a href={`mailto:${content.supportContact.email}`} className="text-brand-600 hover:underline">
|
||||
{content.supportContact.email}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<button className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors shadow-sm" onClick={onClose}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { TONES, type Tone } from "./tones";
|
||||
|
||||
/** A single "quick action" tile — icon chip, title, description, whole tile links out. */
|
||||
export function QuickActionTile({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
tone = "brand",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
tone?: Tone;
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm hover:border-brand-200 hover:shadow transition-shadow"
|
||||
>
|
||||
<div className={"w-9 h-9 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
|
||||
<Icon className={"w-5 h-5 " + t.icon} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900">{title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActionGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">{children}</div>;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ArrowUp, ArrowDown, type LucideIcon } from "lucide-react";
|
||||
import { TONES, type Tone } from "./tones";
|
||||
|
||||
/** A "vs last month"-style delta line. `value` is a percentage; null means no baseline to compare against (shown as "New"). */
|
||||
export type StatDelta = { value: number | null; label?: string };
|
||||
|
||||
/**
|
||||
* Richer stat tile for dashboard KPI rows (icon chip + label + big value +
|
||||
* optional delta + optional "View X" link) — scaffolding for Phase 2's
|
||||
* dashboard rebuilds. Not the same component as reports/StatTile.tsx, which
|
||||
* stays as-is for Reports' own denser tiles.
|
||||
*/
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "brand",
|
||||
href,
|
||||
linkLabel,
|
||||
delta,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: Tone;
|
||||
href?: string;
|
||||
linkLabel?: string;
|
||||
delta?: StatDelta;
|
||||
}) {
|
||||
const t = TONES[tone] || TONES.brand;
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={"w-10 h-10 rounded-lg flex items-center justify-center shrink-0 " + t.bg}>
|
||||
<Icon className={"w-5 h-5 " + t.icon} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500 leading-snug">{label}</div>
|
||||
<div className="text-xl font-semibold text-gray-900 truncate">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
{delta && (
|
||||
<div className={"mt-2 flex items-center gap-1 text-xs font-medium " + (delta.value == null ? "text-gray-400" : delta.value >= 0 ? "text-emerald-600" : "text-rose-600")}>
|
||||
{delta.value == null ? (
|
||||
<span>New this month</span>
|
||||
) : (
|
||||
<>
|
||||
{delta.value >= 0 ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||
<span>{Math.abs(delta.value).toFixed(0)}%</span>
|
||||
<span className="text-gray-400 font-normal">{delta.label || "vs last month"}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{href && (
|
||||
<Link href={href} className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:underline">
|
||||
{linkLabel || "View"}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCardRow({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 gap-4">{children}</div>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user