diff --git a/CHANGELOG.md b/CHANGELOG.md index fd74533..6614bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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). + +### 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`. + +### 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 diff --git a/backend/prisma/migrations/20260806120000_add_security_events/migration.sql b/backend/prisma/migrations/20260806120000_add_security_events/migration.sql new file mode 100644 index 0000000..bec0191 --- /dev/null +++ b/backend/prisma/migrations/20260806120000_add_security_events/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dbc1db2..9f4f75a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/controllers/statsController.js b/backend/src/controllers/statsController.js index a63d3cd..4ecab53 100644 --- a/backend/src/controllers/statsController.js +++ b/backend/src/controllers/statsController.js @@ -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, }; \ No newline at end of file diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index a741920..18a0e6c 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -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, }; \ No newline at end of file diff --git a/backend/src/controllers/whatsappController.js b/backend/src/controllers/whatsappController.js index 0dee38e..ba70f0d 100644 --- a/backend/src/controllers/whatsappController.js +++ b/backend/src/controllers/whatsappController.js @@ -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, diff --git a/backend/src/routes/statsRoutes.js b/backend/src/routes/statsRoutes.js index 16cd881..90267ce 100644 --- a/backend/src/routes/statsRoutes.js +++ b/backend/src/routes/statsRoutes.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js index 1a9c84d..1f8f574 100644 --- a/backend/src/routes/userRoutes.js +++ b/backend/src/routes/userRoutes.js @@ -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('/') diff --git a/backend/src/utils/securityEvents.js b/backend/src/utils/securityEvents.js new file mode 100644 index 0000000..d90a323 --- /dev/null +++ b/backend/src/utils/securityEvents.js @@ -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 }; diff --git a/backend/src/utils/userAgent.js b/backend/src/utils/userAgent.js new file mode 100644 index 0000000..466d175 --- /dev/null +++ b/backend/src/utils/userAgent.js @@ -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 }; diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index a16f977..c442e75 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -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 (
-
-
-

Forgot your password?

-

Enter your account email and we'll send you a link to reset your password.

+
+
+
+
+ +
+

Forgot your password?

+
+

Enter your account email and we'll send you a link to reset your password.

setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
-
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index ea3afae..2b101a1 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -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 (
-
-
-

Login

+
+
+
+
+ +
+
+

Welcome back

+

Log in to manage your events and tickets.

+
+
}> diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 231f2e2..e942c46 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -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 (
-
-
-

Create Account

+
+
+
+
+ +
+
+

Create account

+

One account for all your registrations and tickets.

+
+
}> diff --git a/frontend/src/app/[redirectUrl]/page.tsx b/frontend/src/app/[redirectUrl]/page.tsx index 7c7fb55..e085651 100644 --- a/frontend/src/app/[redirectUrl]/page.tsx +++ b/frontend/src/app/[redirectUrl]/page.tsx @@ -22,7 +22,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{

The page you're looking for doesn't exist.

- + Back to Home →
@@ -37,7 +37,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{

This event has ended

Thanks for joining us! Stay tuned for the next one.

- + Back to Home →
diff --git a/frontend/src/app/activate-account/page.tsx b/frontend/src/app/activate-account/page.tsx index 598c990..a392edb 100644 --- a/frontend/src/app/activate-account/page.tsx +++ b/frontend/src/app/activate-account/page.tsx @@ -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() {
-

Activate your account

+
+
+ +
+

Activate your account

+

Set a password to activate your {appName} account.

{!token &&

Missing or invalid activation link.

}
@@ -58,7 +64,7 @@ function ActivateAccountContent() { {password && confirm && password !== confirm && (

Passwords do not match.

)} -
diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx new file mode 100644 index 0000000..575f17c --- /dev/null +++ b/frontend/src/app/contact/page.tsx @@ -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 ( +
+ +
+
+

Contact {orgName}

+

We'd love to hear from you — reach out any of the ways below.

+
+ + {!loading && !hasAny && ( +

Contact details haven't been set up yet.

+ )} + +
+ {email && ( + +
+ +
+
+
Email
+
{email}
+
+
+ )} + {phone && ( + +
+ +
+
+
Phone
+
{phone}
+
+
+ )} + {address && ( +
+
+ +
+
+
Address
+
{address}
+
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx index 56b6c0e..e190b34 100644 --- a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -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 } from "lucide-react"; const METHOD_LABELS: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"]; @@ -57,12 +58,17 @@ export default function EventCashupPage() { return (
-
-
- -

{data?.event?.title || "Event"} — Cashup

+
+
+
+ +
+
+ +

{data?.event?.title || "Event"} — Cashup

+
- + {isClosed ? "Closed" : "Open"}
@@ -70,9 +76,9 @@ export default function EventCashupPage() { {error &&
{error}
}
- - - + + +
{loading &&
Loading…
} @@ -169,7 +175,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
Event costs
{!isClosed && editingId === null && ( - + )}
@@ -199,7 +205,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: {money(c.total ?? c.amount)} {!isClosed && ( - + )} @@ -264,7 +270,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
- +
)} @@ -378,7 +384,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: { {r.userId && ( - +
); @@ -803,7 +809,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
- +
)} diff --git a/frontend/src/app/dashboard/admin/cashup/page.tsx b/frontend/src/app/dashboard/admin/cashup/page.tsx index f74ae14..8b2783a 100644 --- a/frontend/src/app/dashboard/admin/cashup/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/page.tsx @@ -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 (
-
-
-

Post-event Cashup

-

Set costs, reconcile takings, and close out an event. Admin only.

+
+
+
+ +
+
+

Post-event Cashup

+

Set costs, reconcile takings, and close out an event. Admin only.

+
@@ -87,7 +93,7 @@ export default function CashupLandingPage() { return (
  • router.push(`/dashboard/admin/cashup/${ev.id}`)} >
    @@ -101,7 +107,7 @@ export default function CashupLandingPage() { {ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` – ${new Date(ev.endDate).toLocaleDateString()}` : ""}
  • - Manage → + Manage → ); })} diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx index 4b130a5..e74d177 100644 --- a/frontend/src/app/dashboard/admin/page.tsx +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -6,6 +6,44 @@ 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, +} 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/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 +56,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(null); - const [scanStats, setScanStats] = useStableState(null); - const [activeEventsCount, setActiveEventsCount] = useStableState(0); - const [recentScans, setRecentScans] = useStableState([]); + const [overview, setOverview] = useStableState(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("/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("/api/stats/admin", { authToken: token }), + apiFetch("/api/stats/overview", { authToken: token }), + ]); + setPaymentStats(dash.paymentStats); + setOverview(overviewData); } catch (e) { // ignore errors for dashboard summaries } finally { @@ -61,18 +96,15 @@ export default function AdminDashboardPage() { loadStats(); }, 15000, !!token); + const paymentsTabValue = paymentStats + ? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth + : 0; + return ( -
    -
    -

    Admin Dashboard{user ? ` — ${user.name}` : ""}

    -
    - - - - - - -
    +
    +
    +

    Welcome back{user ? `, ${user.name}` : ""} 👋

    +

    Here's what's happening with your events today.

    {!isAdmin && ( @@ -81,158 +113,89 @@ export default function AdminDashboardPage() {
    )} -
    -
    -
    -
    Quick actions
    -
    - - - - - - - - - - - - - - - -
    -
    + + + + + + + -
    -
    -

    Recent scans

    - {loadingStats && Refreshing…} -
    -
      - {recentScans.map((u: any) => ( -
    • -
      -
      {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
      -
      {new Date(u.scannedAt).toLocaleString()}
      -
      -
      {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}
      -
      Scanned by: {u.scannedBy?.name || u.scannedById}
      -
    • - ))} - {recentScans.length === 0 &&
    • No scans yet.
    • } -
    +
    +
    Quick actions
    + + {QUICK_ACTIONS.map(a => ( + + ))} + +
    + +
    +
    +
    +

    Revenue trend — past month

    + {overview && overview.trend.length > 0 ? ( + ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} /> + ) : ( +
    No revenue recorded in the past month.
    + )}
    -
    -

    Payments

    - {loadingStats && Refreshing…} -
    - {paymentStats ? ( -
    -
    -
    Today
    -
    R{paymentStats.totalToday}
    -
    -
    -
    Past Week
    -
    R{paymentStats.totalWeek}
    -
    -
    -
    Past Month
    -
    R{paymentStats.totalMonth}
    -
    -
    - ) : ( -
    No payment data yet.
    - )} - - {scanStats?.byStaff?.length > 0 && ( -
    -
    Today by staff
    -
      - {scanStats.byStaff.map((s: any) => ( -
    • - {s.name || 'Staff'} - {s.count} -
    • +

      Top performing events

      + {overview && overview.topEvents.length > 0 ? ( + + + + Event + Registrations + Revenue + Tickets sold + + + + {overview.topEvents.map(e => ( + + {e.title} + {formatCount(e.registrations)} + {formatRand(e.revenue)} + {formatCount(e.ticketsSold)} + ))} - - + +
      + ) : ( +
      No event revenue recorded yet.
      )}
    -
    +
    -

    Admin stats

    - {loadingStats &&
    Loading…
    } -
    -
    -
    -
    Active events
    -
    {activeEventsCount}
    -
    - -
    -
    -
    Revenue today
    -
    R {(paymentStats?.totalToday || 0).toFixed(2)}
    -
    -
    -
    Donations today
    -
    {paymentStats?.donationsToday || 0}
    -
    +
    +

    Payments overview

    + {loadingStats && Refreshing…}
    +
    + {(["today", "week", "month"] as const).map(t => ( + + ))} +
    + {paymentStats ? ( +
    {formatRand(paymentsTabValue)}
    + ) : ( +
    No payment data yet.
    + )}
    -
    -

    As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.

    -
    +

    As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.

    diff --git a/frontend/src/app/dashboard/admin/registrations/page.tsx b/frontend/src/app/dashboard/admin/registrations/page.tsx index 118d938..e67402e 100644 --- a/frontend/src/app/dashboard/admin/registrations/page.tsx +++ b/frontend/src/app/dashboard/admin/registrations/page.tsx @@ -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 (
    -
    -

    Manage Registrations

    - +
    +
    +
    + +
    +
    +

    Manage Registrations

    +

    {registrations.length} registration{registrations.length !== 1 ? "s" : ""} total

    +
    +
    +
    {!isAdmin && ( @@ -304,7 +313,7 @@ export default function AdminRegistrationsPage() { {/* Actions */}
    setOrgName(e.target.value)} /> - - - setOrgTagline(e.target.value)} /> - -
    - - setOrgEmail(e.target.value)} /> +
    + {/* ── Organisation ─────────────────────────────────────────────────── */} + {activeTab === "organisation" && ( +
    +

    Organisation details

    + + setOrgName(e.target.value)} /> - - setOrgPhone(e.target.value)} /> + + setOrgTagline(e.target.value)} /> -
    - - setOrgAddress(e.target.value)} /> - - - setAppBaseUrl(e.target.value)} /> - - -
    - )} - - {/* ── Branding ─────────────────────────────────────────────────────── */} - {activeTab === "branding" && ( -
    - -
    - setAccentColor(e.target.value)} /> - setAccentColor(e.target.value)} /> +
    + + setOrgEmail(e.target.value)} /> + + + setOrgPhone(e.target.value)} /> +
    -
    - + + setOrgAddress(e.target.value)} /> + + + setAppBaseUrl(e.target.value)} /> + + +
    + )} - - {currentLogoSrc && ( -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - Current logo + {/* ── Branding ─────────────────────────────────────────────────────── */} + {activeTab === "branding" && ( +
    +

    Branding

    + +
    + setAccentColor(e.target.value)} /> + setAccentColor(e.target.value)} /> +
    +
    + + + + {currentLogoSrc && ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + Current logo + +
    + )} + { + const file = e.target.files?.[0]; + if (!file) return; + setLogoFile(file); + setLogoPreview(URL.createObjectURL(file)); + }} className="text-sm" /> +
    + + +
    + )} + + {/* ── Notifications ─────────────────────────────────────────────────── */} + {activeTab === "notifications" && ( +
    +

    Notifications

    + + setNotifEmails(e.target.value)} /> + + +
    + )} + + {/* ── Email / SMTP ──────────────────────────────────────────────────── */} + {activeTab === "email" && ( +
    +

    Email

    +

    + Outgoing email for tickets, payment confirmations, and account notifications. + Leave blank to use server environment variables. The password is stored encrypted. +

    + +
    + + setSmtpHost(e.target.value)} /> + + + setSmtpPort(e.target.value)} /> + +
    + +
    + setSmtpSecure(e.target.checked)} className="rounded" /> + +
    + + + setSmtpFrom(e.target.value)} /> + + +
    + + setSmtpUser(e.target.value)} autoComplete="username" /> + + +
    + setSmtpPass(e.target.value)} autoComplete="new-password" /> + {smtpPassSet && smtpPass === "" && ( + ● saved + )} +
    +
    +
    + + {/* Test connection */} +
    +
    -
    - )} - { - const file = e.target.files?.[0]; - if (!file) return; - setLogoFile(file); - setLogoPreview(URL.createObjectURL(file)); - }} className="text-sm" /> - - - -
    - )} - - {/* ── Notifications ─────────────────────────────────────────────────── */} - {activeTab === "notifications" && ( -
    - - setNotifEmails(e.target.value)} /> - - -
    - )} - - {/* ── Email / SMTP ──────────────────────────────────────────────────── */} - {activeTab === "email" && ( -
    -

    - Outgoing email for tickets, payment confirmations, and account notifications. - Leave blank to use server environment variables. The password is stored encrypted. -

    - -
    - - setSmtpHost(e.target.value)} /> - - - setSmtpPort(e.target.value)} /> - -
    - -
    - setSmtpSecure(e.target.checked)} className="rounded" /> - -
    - - - setSmtpFrom(e.target.value)} /> - - -
    - - setSmtpUser(e.target.value)} autoComplete="username" /> - - -
    - setSmtpPass(e.target.value)} autoComplete="new-password" /> - {smtpPassSet && smtpPass === "" && ( - ● saved + {smtpTestResult && ( + + {smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message} + )}
    -
    -
    - - {/* Test connection */} -
    -
    - - {smtpTestResult && ( - - {smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message} - + {smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && ( +
    + Show technical details +
    {smtpTestResult.raw}
    +
    )}
    - {smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && ( -
    - Show technical details -
    {smtpTestResult.raw}
    -
    - )} + +
    + )} - -
    - )} + {/* ── Legal ─────────────────────────────────────────────────────────── */} + {activeTab === "legal" && ( +
    +

    Legal

    +

    + These values populate the Terms of Use and Privacy Policy pages automatically. +

    - {/* ── Legal ─────────────────────────────────────────────────────────── */} - {activeTab === "legal" && ( -
    -

    - These values populate the Terms of Use and Privacy Policy pages automatically. -

    + + setLegalOperatorName(e.target.value)} /> + + + setLegalWebsiteUrl(e.target.value)} /> + + + setLegalEffectiveDate(e.target.value)} /> + - - setLegalOperatorName(e.target.value)} /> - - - setLegalWebsiteUrl(e.target.value)} /> - - - setLegalEffectiveDate(e.target.value)} /> - - -
    -

    Information Officer (POPIA)

    -
    - - setLegalIoName(e.target.value)} /> - - - setLegalIoEmail(e.target.value)} /> - +
    +

    Information Officer (POPIA)

    +
    + + setLegalIoName(e.target.value)} /> + + + setLegalIoEmail(e.target.value)} /> + +
    -
    - -
    - )} + +
    + )} + + {/* ── WhatsApp ──────────────────────────────────────────────────────── */} + {activeTab === "whatsapp" && } +
    ); -} \ No newline at end of file +} + +// ─── 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 = { + 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 = { + 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 ( + + + + + ); +} + +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
    {children}
    ; +} + +function StepIndicator({ step }: { step: number }) { + const steps = [ + { n: 1, label: "Access Token" }, + { n: 2, label: "Session Instance" }, + { n: 3, label: "Connected" }, + ]; + return ( +
    + {steps.map((s, i) => { + const done = step > s.n; + const current = step === s.n; + return ( + +
    +
    + {done ? "✓" : s.n} +
    + {s.label} +
    + {i < steps.length - 1 && ( +
    + )} + + ); + })} +
    + ); +} + +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 = { + 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 ( + + ); +} + +/** `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(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(null); + const [statusMsg, setStatusMsg] = useState(null); + const [qrSrc, setQrSrc] = useState(null); + const [pairingPhone, setPairingPhone] = useState(""); + + const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); + const [busy, setBusy] = useState(null); + + const fetchConfig = useCallback(async () => { + if (!token) return; + try { + const res = await apiFetch("/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("/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(`/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("/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 ( +
    + Loading… +
    + ); + } + + return ( +
    +
    +

    WhatsApp integration

    +

    Powered by WAWP — used to send tickets and notifications via WhatsApp.

    +
    + + + + {actionMsg && {actionMsg.text}} + + {step === 1 && ( +
    +

    Step 1 — Enter your WAWP Access Token

    +

    + Your access token is found in your WAWP account dashboard at{" "} + app.wawp.net. +

    +
    + + 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" + /> +
    + +
    + )} + + {step === 2 && ( +
    +
    +

    Step 2 — Set Up Session Instance

    + Token: {cfg?.tokenMasked} +
    +

    You need a WAWP session instance. Either create a brand-new one, or enter an existing Instance ID.

    + +
    + + +
    + + {instanceMode === "create" && ( +
    +

    Click below to create a new WAWP session. The Instance ID will be saved automatically.

    + +
    + )} + + {instanceMode === "enter" && ( +
    +
    + + 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" + /> +
    + +
    + )} + + +
    + )} + + {step === 3 && ( + <> +
    +
    +

    Session Status

    + +
    + + {status === null ? ( +
    Fetching status…
    + ) : ( +
    + {STATUS_ICONS[status] ?? "⚪"} + {status} +
    + )} + + {statusMsg &&

    {statusMsg}

    } + + {status === "FAILED" && ( + The session has failed. The system will attempt to auto-restart. You can also restart manually below. + )} + + {status && POLLING_STATUSES.has(status) && ( +

    Auto-refreshing every 5 seconds…

    + )} + +
    + Token: {cfg?.tokenMasked || "—"} + Instance: {cfg?.instanceId || "—"} +
    +
    + + {status === "SCAN_QR_CODE" && ( +
    +
    +

    Scan QR Code

    + +
    +

    Open WhatsApp → Linked Devices → Link a Device, then scan the code below.

    + {qrSrc ? ( + // eslint-disable-next-line @next/next/no-img-element + WhatsApp QR Code + ) : ( +
    Loading QR…
    + )} +

    QR codes expire after ~20 seconds — click Refresh QR if it stops working.

    +
    + )} + + {status === "SCAN_QR_CODE" && ( +
    +

    Link by Phone Number Instead

    +

    Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing code on your phone.

    +
    + 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" + /> + +
    +
    + )} + +
    +

    Session Controls

    +
    + doAction("start")} /> + doAction("restart")} /> + { + if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return; + doAction("logout"); + }} /> +
    +
    + +
    +

    Instance Management

    +

    Create a brand-new instance or permanently delete the current one. Deleting will require you to set up a new instance.

    +
    + doAction("create-instance")} /> + { + if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return; + doAction("delete-instance"); + }} /> +
    +
    + +
    + + Update Credentials + expand ▾ + +
    +

    Change your WAWP access token or Instance ID. Leave a field blank to keep the current value.

    +
    + + 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" /> +
    +
    + + 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" /> +
    + +
    +
    + + )} + +

    + WhatsApp notifications powered by{" "} + WAWP + . Session auto-recovers on failure; admin alert sent if recovery fails. +

    +
    + ); +} diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index 06873a3..34556be 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -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 (
    -
    -

    User Management

    +
    +
    +
    + +
    +
    +

    User Management

    +

    {total} user{total !== 1 ? "s" : ""} total

    +
    +
    - - +
    @@ -249,7 +259,7 @@ export default function AdminUsersPage() {
    -
    @@ -298,8 +308,7 @@ export default function AdminUsersPage() {
    - {total} user{total !== 1 ? "s" : ""} total - {total > 0 && ` — page ${page} of ${totalPages}`} + {total > 0 && `Page ${page} of ${totalPages}`}
    @@ -325,7 +334,7 @@ export default function AdminUsersPage() { {u.email} - {u.role} + {u.phoneNumber || ""} @@ -334,7 +343,10 @@ export default function AdminUsersPage() { {u.notificationPreference || "email"} - {u.isActive ? "Yes" : "No"} + + + {u.isActive ? "Active" : "Inactive"} +
    @@ -384,7 +396,7 @@ export default function AdminUsersPage() { ) : (
    - +
    diff --git a/frontend/src/app/dashboard/admin/whatsapp/page.tsx b/frontend/src/app/dashboard/admin/whatsapp/page.tsx index 5ae404d..773a548 100644 --- a/frontend/src/app/dashboard/admin/whatsapp/page.tsx +++ b/frontend/src/app/dashboard/admin/whatsapp/page.tsx @@ -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 = { - 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 = { - 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 ( - - - - - ); -} - -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 ( -
    {children}
    - ); -} - -// ─── 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(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(null); - const [statusMsg, setStatusMsg] = useState(null); - const [qrSrc, setQrSrc] = useState(null); - const [pairingPhone, setPairingPhone] = useState(""); - - // ── Shared action feedback ─────────────────────────────────────────────────── - const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null); - const [busy, setBusy] = useState(null); - - // ── Load config ────────────────────────────────────────────────────────────── - const fetchConfig = useCallback(async () => { - if (!token) return; - try { - const res = await apiFetch("/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("/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(`/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("/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 ( -
    - Loading… -
    - ); - } - - return ( -
    - {/* Back button */} - - - {/* Header */} -
    - 💬 -
    -

    WhatsApp Integration

    -

    Powered by WAWP

    -
    -
    - - {/* Step indicator */} - - - {/* Global action message */} - {actionMsg && ( - {actionMsg.text} - )} - - {/* ── STEP 1: Enter access token ──────────────────────────────────────── */} - {step === 1 && ( -
    -

    Step 1 — Enter your WAWP Access Token

    -

    - Your access token is found in your WAWP account dashboard at{" "} - - app.wawp.net - - . -

    -
    - - 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" - /> -
    - -
    - )} - - {/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */} - {step === 2 && ( -
    -
    -

    Step 2 — Set Up Session Instance

    - - Token: {cfg?.tokenMasked} - -
    -

    - You need a WAWP session instance. Either create a brand-new one, or enter an - existing Instance ID. -

    - - {/* Tab toggle */} -
    - - -
    - - {instanceMode === "create" && ( -
    -

    - Click below to create a new WAWP session. The Instance ID will be saved - automatically. -

    - -
    - )} - - {instanceMode === "enter" && ( -
    -
    - - 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" - /> -
    - -
    - )} - - -
    - )} - - {/* ── STEP 3: Full management ─────────────────────────────────────────── */} - {step === 3 && ( - <> - {/* Status card */} -
    -
    -

    Session Status

    - -
    - - {status === null ? ( -
    - Fetching status… -
    - ) : ( -
    - {STATUS_ICONS[status] ?? "⚪"} - - {status} - -
    - )} - - {statusMsg &&

    {statusMsg}

    } - - {status === "FAILED" && ( - - The session has failed. The system will attempt to auto-restart. You can also - restart manually below. - - )} - - {status && POLLING_STATUSES.has(status) && ( -

    - Auto-refreshing every 5 seconds… -

    - )} - - {/* Config info strip */} -
    - - Token: {cfg?.tokenMasked || "—"} - - - Instance: {cfg?.instanceId || "—"} - -
    -
    - - {/* QR Code */} - {status === "SCAN_QR_CODE" && ( -
    -
    -

    Scan QR Code

    - -
    -

    - Open WhatsApp → Linked Devices → Link a Device, then scan the code below. -

    - {qrSrc ? ( - WhatsApp QR Code - ) : ( -
    - Loading QR… -
    - )} -

    - QR codes expire after ~20 seconds — click Refresh QR if it stops working. -

    -
    - )} - - {/* Pairing code */} - {status === "SCAN_QR_CODE" && ( -
    -

    Link by Phone Number Instead

    -

    - Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing - code on your phone. -

    -
    - 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" - /> - -
    -
    - )} - - {/* Session controls */} -
    -

    Session Controls

    -
    - doAction("start")} - /> - doAction("restart")} - /> - { - if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return; - doAction("logout"); - }} - /> -
    -
    - - {/* Instance management */} -
    -

    Instance Management

    -

    - Create a brand-new instance or permanently delete the current one. Deleting will - require you to set up a new instance. -

    -
    - doAction("create-instance")} - /> - { - if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return; - doAction("delete-instance"); - }} - /> -
    -
    - - {/* Update credentials */} -
    - - Update Credentials - expand ▾ - -
    -

    - Change your WAWP access token or Instance ID. Leave a field blank to keep the - current value. -

    -
    - - 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" - /> -
    -
    - - 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" - /> -
    - -
    -
    - - )} - -

    - WhatsApp notifications powered by{" "} - - WAWP - - . Session auto-recovers on failure; admin alert sent if recovery fails. -

    -
    - ); + router.replace("/dashboard/admin/settings?tab=whatsapp"); + }, [router]); + return
    Redirecting…
    ; } - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function StepIndicator({ step }: { step: number }) { - const steps = [ - { n: 1, label: "Access Token" }, - { n: 2, label: "Session Instance" }, - { n: 3, label: "Connected" }, - ]; - return ( -
    - {steps.map((s, i) => { - const done = step > s.n; - const current = step === s.n; - return ( - -
    -
    - {done ? "✓" : s.n} -
    - - {s.label} - -
    - {i < steps.length - 1 && ( -
    - )} - - ); - })} -
    - ); -} - -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 = { - 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 ( - - ); -} \ No newline at end of file diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index 6606f64..0b41143 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -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 ( +
    + + +
    + +
    +
    {children}
    +
    +
    +
    +
    + ); + } return (
    -
    - {!hideSidebar && ( - <> - {/* Mobile dropdown navigation */} - - {/* Desktop sidebar */} -
    - -
    - - )} -
    {children}
    -
    +
    {children}
    ); diff --git a/frontend/src/app/dashboard/staff/event-tickets/page.tsx b/frontend/src/app/dashboard/staff/event-tickets/page.tsx index 4132fcb..9d4ab47 100644 --- a/frontend/src/app/dashboard/staff/event-tickets/page.tsx +++ b/frontend/src/app/dashboard/staff/event-tickets/page.tsx @@ -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 (
    -
    -

    Event Tickets

    +
    +
    +
    + +
    +

    Event Tickets

    +
    diff --git a/frontend/src/app/dashboard/staff/page.tsx b/frontend/src/app/dashboard/staff/page.tsx index c80ea4a..571a5f0 100644 --- a/frontend/src/app/dashboard/staff/page.tsx +++ b/frontend/src/app/dashboard/staff/page.tsx @@ -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 ( -
    -
    -

    Staff Dashboard{user ? ` — ${user.name}` : ""}

    -
    - - -
    +
    +
    +

    Welcome back{user ? `, ${user.name}` : ""} 👋

    +

    Here's today's scanning activity.

    {!canView && ( @@ -75,20 +80,23 @@ export default function StaffDashboardPage() {
    )} + + + + + + +
    +
    Quick actions
    + + {QUICK_ACTIONS.map(a => ( + + ))} + +
    +
    -
    -
    Quick actions
    -
    - - -
    -
    -

    Recent scans

    @@ -101,7 +109,7 @@ export default function StaffDashboardPage() {
    {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
    {new Date(u.scannedAt).toLocaleString()}
    -
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}
    +
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}
    Scanned by: {u.scannedBy?.name || u.scannedById}
    ))} @@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
    -

    Scanner stats

    - {loadingStats &&
    Loading stats…
    } - {stats && ( -
    -
    -
    Today
    -
    {stats.totalToday}
    -
    -
    -
    My scans
    -
    {stats.myToday}
    -
    -
    -
    Last hour
    -
    {stats.lastHour}
    -
    -
    - )} - - {stats?.byStaff?.length > 0 && ( -
    -
    Today by staff
    -
      - {stats.byStaff.map((s: any) => ( -
    • - {s.name || 'Staff'} - {s.count} -
    • - ))} -
    -
    +

    Today by staff

    + {stats?.byStaff?.length > 0 ? ( +
      + {stats.byStaff.map((s: any) => ( +
    • + {s.name || 'Staff'} + {s.count} +
    • + ))} +
    + ) : ( +
    No scans recorded yet today.
    )}
    diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx index 6ba6019..6d3aa47 100644 --- a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx +++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx @@ -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() {
    -
    -

    Ticket Scanning

    - +
    +
    +
    + +
    +

    Ticket Scanning

    +
    +

    Use the button to start/stop scanning. The back camera will be used when available. @@ -363,7 +369,7 @@ export default function TicketScanningPage() { {confirmModal && (

    -

    Confirm Scan

    +

    Confirm Scan

    Review the ticket details before confirming.

    @@ -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() { diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 76634cf..ec72617 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -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 } from "lucide-react"; type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund"; @@ -343,7 +344,12 @@ export default function AtTheDoorPage() {
    -

    At The Door

    +
    +
    + +
    +

    At The Door

    +
    setTab('attendees')} /> Attendees -
    diff --git a/frontend/src/app/dashboard/supervisor/event-options/page.tsx b/frontend/src/app/dashboard/supervisor/event-options/page.tsx index 8cd545c..8bb6f5b 100644 --- a/frontend/src/app/dashboard/supervisor/event-options/page.tsx +++ b/frontend/src/app/dashboard/supervisor/event-options/page.tsx @@ -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 // This returns local time (browser timezone) as YYYY-MM-DDTHH:mm @@ -85,8 +86,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: )}
    - - + +
    )} @@ -202,9 +203,14 @@ function EventOptionsContent() { return (
    -
    -

    Event options

    - +
    +
    +
    + +
    +

    Event options

    +
    +
    {!canView && ( @@ -280,7 +286,7 @@ function EventOptionsContent() { setNewOpt({ ...newOpt, name: e.target.value })} /> setNewOpt({ ...newOpt, price: e.target.value })} /> - +
    diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index ec406d8..8ef00eb 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -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 = ""; } }} />
    @@ -120,7 +121,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E {fields.map((f, idx) => (
  • - {typeLabel(f.type)} + {typeLabel(f.type)} #{idx + 1}
    @@ -149,7 +150,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E )}
    - +
    @@ -204,7 +205,7 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
    ); })} - +
    ); } @@ -254,7 +255,7 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
  • ))} - +
    ); @@ -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); }} /> ))} - +
    ); @@ -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); }} /> ))} - +
    ); })} @@ -414,7 +415,7 @@ function SectionsDraftEditor({ sections, options, onChange }: {
    )} - +
    ); @@ -479,7 +480,7 @@ function SectionsManager({ eventId }: { eventId: string }) { ))}
    - +
    ); @@ -501,7 +502,7 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) { ))}
    - +
    @@ -557,7 +558,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
      {items.map(it => (
    • - {it.originalName} + {it.originalName}
    • ))} @@ -671,7 +672,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No className="border rounded bg-white shadow-lg overflow-y-auto" > {visibleResults.map(u => ( -
    • add(u)}> +
    • add(u)}> {u.name} {u.email}
    • ))} @@ -1140,7 +1141,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { ))} @@ -1344,13 +1345,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { {mode === "create" ? ( isLastStep && isOnLastSubstep ? ( ) : ( + 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 → ) ) : ( <> @@ -1360,7 +1361,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { )} @@ -1379,7 +1380,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) { const isInactive = ev.isActive === false; const isClosed = ev.cashupStatus === "closed"; return ( -
    • +
    • {ev.title} @@ -1393,7 +1394,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) { {ev.price != null ? R{Number(ev.price).toFixed(0)} : ""}
      - Edit → + Edit →
    • ); } @@ -1461,14 +1462,19 @@ export default function ManageEventsPage() { return (
      -
      -
      -

      Events

      -

      Manage and create events

      +
      +
      +
      + +
      +
      +

      Events

      +

      Manage and create events

      +
      - - + +
      diff --git a/frontend/src/app/dashboard/supervisor/forms/page.tsx b/frontend/src/app/dashboard/supervisor/forms/page.tsx index 853e4ff..70948e4 100644 --- a/frontend/src/app/dashboard/supervisor/forms/page.tsx +++ b/frontend/src/app/dashboard/supervisor/forms/page.tsx @@ -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) => (
    • - {typeLabel(f.type)} + {typeLabel(f.type)} #{idx + 1}
      @@ -109,7 +110,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
    )}
    - +
    @@ -438,12 +439,17 @@ export default function FormsBrowserPage() { return (
    -
    -

    Forms

    +
    +
    +
    + +
    +

    Forms

    +
    - + {mode === 'view' && ( - )} @@ -459,7 +465,7 @@ export default function FormsBrowserPage() { {/* Mode tabs */}
    {(['view', 'fill', 'manage'] as const).map(m => ( -
    - +
    ); } diff --git a/frontend/src/app/dashboard/supervisor/manual/page.tsx b/frontend/src/app/dashboard/supervisor/manual/page.tsx index 67df94d..88481c2 100644 --- a/frontend/src/app/dashboard/supervisor/manual/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual/page.tsx @@ -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 } from "lucide-react"; // ─── Pricing helpers ───────────────────────────────────────────────────────── @@ -337,9 +338,14 @@ export default function ManualRegistrationPage() { return (
    -
    -

    Manual registration

    - +
    +
    +
    + +
    +

    Manual registration

    +
    +
    {!canView && ( @@ -349,11 +355,11 @@ export default function ManualRegistrationPage() { )}
    -
    diff --git a/frontend/src/app/dashboard/supervisor/page.tsx b/frontend/src/app/dashboard/supervisor/page.tsx index dbfd6ad..a62818b 100644 --- a/frontend/src/app/dashboard/supervisor/page.tsx +++ b/frontend/src/app/dashboard/supervisor/page.tsx @@ -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(null); const [paymentStats, setPaymentStats] = useStableState(null); - const [activeEventsCount, setActiveEventsCount] = useStableState(0); const [recentScans, setRecentScans] = useStableState([]); + const [overview, setOverview] = useStableState(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("/api/stats/supervisor", { authToken: token }); + const [data, overviewData] = await Promise.all([ + apiFetch("/api/stats/supervisor", { authToken: token }), + apiFetch("/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 ( -
    -
    -

    Supervisor Dashboard{user ? ` — ${user.name}` : ""}

    -
    - - - -
    +
    +
    +

    Welcome back{user ? `, ${user.name}` : ""} 👋

    +

    Here's what's happening with your events today.

    {!canView && ( @@ -81,52 +113,63 @@ export default function SupervisorDashboardPage() {
    )} + + + + + + + + +
    +
    Quick actions
    + + {QUICK_ACTIONS.map(a => ( + + ))} + +
    +
    -
    -
    -
    Quick actions
    -
    - - - - - - - - - - - -
    +
    +
    +

    Revenue trend — past month

    + {overview && overview.trend.length > 0 ? ( + ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} /> + ) : ( +
    No revenue recorded in the past month.
    + )}
    -
    +
    +

    Top performing events

    + {overview && overview.topEvents.length > 0 ? ( + + + + Event + Registrations + Revenue + Tickets sold + + + + {overview.topEvents.map(e => ( + + {e.title} + {formatCount(e.registrations)} + {formatRand(e.revenue)} + {formatCount(e.ticketsSold)} + + ))} + +
    + ) : ( +
    No event revenue recorded yet.
    + )} +
    + +

    Recent scans

    {loadingStats && Refreshing…} @@ -138,21 +181,23 @@ export default function SupervisorDashboardPage() {
    {u.ticket?.event?.title || u.ticket?.eventId || 'Event'}
    {new Date(u.scannedAt).toLocaleString()}
    -
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0,8)}
    +
    {u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} — #{String(u.ticket?.id || '').slice(0, 8)}
    Scanned by: {u.scannedBy?.name || u.scannedById}
    ))} {recentScans.length === 0 &&
  • No scans yet.
  • }
    +
    +

    Scanner activity

    {loadingStats && Refreshing…}
    {scanStats ? ( -
    +
    Today
    {scanStats.totalToday}
    @@ -169,9 +214,21 @@ export default function SupervisorDashboardPage() { ) : (
    No scanner data yet.
    )} +
    + +
    +

    Payments today

    + {paymentStats ? ( +
    +
    Today
    +
    {formatRand(paymentStats.totalToday)}
    +
    + ) : ( +
    No payment data yet.
    + )} {scanStats?.byStaff?.length > 0 && ( -
    +
    Today by staff
      {scanStats.byStaff.map((s: any) => ( @@ -185,30 +242,6 @@ export default function SupervisorDashboardPage() { )}
    - -
    -
    -

    Supervisor stats

    - {loadingStats &&
    Loading…
    } -
    -
    -
    -
    Active events
    -
    {activeEventsCount}
    -
    - -
    -
    -
    Revenue today
    -
    R {(paymentStats?.totalToday || 0).toFixed(2)}
    -
    -
    -
    Donations today
    -
    {paymentStats?.donationsToday || 0}
    -
    -
    -
    -
    ); diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index 6b4bdd4..b283dd8 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -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 } from "lucide-react"; // A donation is never mutated once created — assigning it to a registration creates a separate // "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. @@ -85,7 +86,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n return (
    {matches.length} result{matches.length !== 1 ? "s" : ""}
    {matches.map(u => ( - +
    +
    +
    + +
    +

    Payments

    +
    +
    {!canView && ( @@ -560,23 +566,23 @@ function PaymentsContent() { {info &&
    {info}
    }
    -
    - +
    @@ -752,7 +758,7 @@ function PaymentsContent() {
    - +
    )} @@ -806,7 +812,7 @@ function PaymentsContent() {
    -
    @@ -816,7 +822,7 @@ function PaymentsContent() {
    {linkResult.redirectUrl}
    - +
    Generating a new link for this registration will replace this one — the old link will no longer be honored.
    @@ -1203,7 +1209,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi diff --git a/frontend/src/app/dashboard/supervisor/reports/page.tsx b/frontend/src/app/dashboard/supervisor/reports/page.tsx index 0984015..2841ed2 100644 --- a/frontend/src/app/dashboard/supervisor/reports/page.tsx +++ b/frontend/src/app/dashboard/supervisor/reports/page.tsx @@ -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 (
    - router.push('/dashboard')} /> + Loading…
    }> + router.push('/dashboard')} /> +
    ); } diff --git a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx index 34cc97e..363f69e 100644 --- a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx @@ -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 { MessageCircle } from "lucide-react"; // Attendee with preference info type Attendee = { id: string; name: string; phone: string; pref: string }; @@ -590,9 +591,14 @@ function WhatsAppAttendeesPageInner() { return (
    -
    -

    WhatsApp Attendees

    -
    diff --git a/frontend/src/app/dashboard/user/donate/page.tsx b/frontend/src/app/dashboard/user/donate/page.tsx index e17ba79..2fae543 100644 --- a/frontend/src/app/dashboard/user/donate/page.tsx +++ b/frontend/src/app/dashboard/user/donate/page.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; +import { HandHeart } from "lucide-react"; export default function DonatePage() { const { token } = useAuth(); @@ -57,32 +58,43 @@ export default function DonatePage() { return (
    -

    Make a donation

    - {error &&

    {error}

    } - {info &&

    {info}

    } +
    +
    + +
    +
    +

    Make a donation

    +

    Support an event or the ministry directly.

    +
    +
    - - +
    + {error &&

    {error}

    } + {info &&

    {info}

    } - - setAmount(e.target.value)} - className="w-full border rounded px-3 py-2 mb-1" - /> -

    Minimum donation is R15.

    + + - + + setAmount(e.target.value)} + className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" + /> +

    Minimum donation is R15.

    + + +
    ); } diff --git a/frontend/src/app/dashboard/user/forms/page.tsx b/frontend/src/app/dashboard/user/forms/page.tsx index 222d298..4cc9cd8 100644 --- a/frontend/src/app/dashboard/user/forms/page.tsx +++ b/frontend/src/app/dashboard/user/forms/page.tsx @@ -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 (
    -
    -

    Attendee forms

    - +
    +
    +
    + +
    +

    Attendee forms

    +
    +
    {loading &&
    Loading…
    } @@ -214,7 +220,7 @@ function FormsContent() { ))}
    - +
    diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 0ae717e..f1562ca 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -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 ( -
    -
    -

    Welcome{user ? `, ${user.name}` : ""}

    +
    +
    +
    +

    Welcome{user ? `, ${user.name}` : ""} 👋

    +

    Here's what's happening with your events.

    +
    @@ -695,16 +700,21 @@ export default function UserDashboardPage() { {loading &&

    Loading…

    } -
    -
    -

    My Registrations

    -
    +
    ); -} \ No newline at end of file +} diff --git a/frontend/src/app/dashboard/user/reset-password/page.tsx b/frontend/src/app/dashboard/user/reset-password/page.tsx index 59bc143..260f028 100644 --- a/frontend/src/app/dashboard/user/reset-password/page.tsx +++ b/frontend/src/app/dashboard/user/reset-password/page.tsx @@ -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 (
    -
    -

    Reset password

    +
    +
    +
    + +
    +

    Reset password

    +
    @@ -96,7 +102,7 @@ export default function ResetPasswordPage() { + ); + } + if (closed) { + return ( + + ); + } + if (eventSoldOut) { + return ( + + ); + } + return ( + + Register + + ); +} + export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; let event: Event; @@ -64,138 +103,159 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id return (
    -
    -
    -

    {event.title}

    -

    {formatDateTimeRange(event.startDate, event.endDate)}

    - - {event.picture && ( - - )} -

    {event.description}

    - {event.attachments && event.attachments.length > 0 && ( +
    +
    +
    + {event.picture ? ( + + ) : ( +
    + +
    + )} +
    -

    Downloads

    - +

    {event.title}

    +

    + + {formatDateTimeRange(event.startDate, event.endDate)} +

    +
    + +
    - )} -

    Tickets

    -
    - {(event.eventOptions || []).map((opt) => { - const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0; - const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0; - const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0); - if (hasVariants) { - const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price); - const minPrice = Math.min(...prices); - const maxPrice = Math.max(...prices); - const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`; - return ( -
    -
    - {opt.name} -
    - {priceLabel} - {soldOut && Sold out} - {nearlyOut && !soldOut && {opt.availableCount} remaining} -
    -
    -
      - {opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => { - const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price; - const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0; - const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0); - return ( -
    • - {v.name} -
      - {vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`} - {vSoldOut && Sold out} - {vNearlyOut && !vSoldOut && {v.availableCount} remaining} -
      -
    • - ); - })} -
    -
    - ); - } +

    {event.description}

    - // Early bird pricing display - const now = new Date(); - const activeTiers = (opt.earlyBirdTiers || []) - .filter((t) => now < new Date(t.deadline)) - .sort((a, b) => a.price - b.price); - const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price; - - return ( -
    - {opt.name} -
    - {activeTiers.length > 0 ? ( - <> - R{displayPrice.toFixed(2)} - R{opt.price.toFixed(2)} - Early bird - - ) : ( - {displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`} - )} - {soldOut && Sold out} - {nearlyOut && !soldOut && {opt.availableCount} remaining} -
    + {event.attachments && event.attachments.length > 0 && ( +
    +
    + +

    Downloads

    - ); - })} + +
    + )} + + {/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */} +
    +
    + +

    Tickets

    +
    + +
    + +
    +
    +
    + +
    +
    +
    + +

    Tickets

    +
    + +
    + +
    +
    - {(() => { - 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 ( - - ); - } - if (closed) { - return ( - - ); - } - if (eventSoldOut) { - return ( - - ); - } - return ( - - Register - - ); - })()}
    ); } + +function TicketList({ event }: { event: Event }) { + const options = event.eventOptions || []; + if (options.length === 0) { + return ( +
    + +

    Free entry — no tickets required.

    +
    + ); + } + return ( +
    + {options.map((opt) => { + const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0; + const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0; + const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0); + + if (hasVariants) { + const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free – R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`; + return ( +
    +
    + {opt.name} +
    + {priceLabel} + {soldOut && Sold out} + {nearlyOut && !soldOut && {opt.availableCount} remaining} +
    +
    +
      + {opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => { + const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price; + const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0; + const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0); + return ( +
    • + {v.name} +
      + {vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`} + {vSoldOut && Sold out} + {vNearlyOut && !vSoldOut && {v.availableCount} remaining} +
      +
    • + ); + })} +
    +
    + ); + } + + // Early bird pricing display + const now = new Date(); + const activeTiers = (opt.earlyBirdTiers || []) + .filter((t) => now < new Date(t.deadline)) + .sort((a, b) => a.price - b.price); + const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price; + + return ( +
    + {opt.name} +
    + {activeTiers.length > 0 ? ( + <> + R{displayPrice.toFixed(2)} + R{opt.price.toFixed(2)} + Early bird + + ) : ( + {displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`} + )} + {soldOut && Sold out} + {nearlyOut && !soldOut && {opt.availableCount} remaining} +
    +
    + ); + })} +
    + ); +} diff --git a/frontend/src/app/events/page.tsx b/frontend/src/app/events/page.tsx index f006ba7..427f924 100644 --- a/frontend/src/app/events/page.tsx +++ b/frontend/src/app/events/page.tsx @@ -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() {
    -

    All Events

    +
    +
    + +
    +
    +

    All Events

    +

    {sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}

    +
    +
    {sorted.length === 0 ? ( -

    No events available.

    +
    + +

    No events available right now — check back soon.

    +
    ) : (
    {sorted.map((event) => ( diff --git a/frontend/src/app/forms/page.tsx b/frontend/src/app/forms/page.tsx index faa46b2..46f41f4 100644 --- a/frontend/src/app/forms/page.tsx +++ b/frontend/src/app/forms/page.tsx @@ -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
    Missing registrationId.
    ; + if (!registrationId) { + return ( +
    + +
    +
    Missing registrationId.
    +
    +
    +
    + ); + } return ( -
    +
    + +
    -

    Attendee forms

    - +
    +
    + +
    +

    Attendee forms

    +
    +
    {loading &&
    Loading…
    } @@ -231,7 +251,7 @@ function FormsContent() { ))}
    - +
    @@ -240,6 +260,8 @@ function FormsContent() { {eventForm && remaining === 0 && (
    All required attendee forms are completed for this registration.
    )} +
    +
    ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3117b6c..ccec240 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 6454609..f07932c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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 }) {children} + diff --git a/frontend/src/app/legal/privacy/page.tsx b/frontend/src/app/legal/privacy/page.tsx index 74ea10a..18d658c 100644 --- a/frontend/src/app/legal/privacy/page.tsx +++ b/frontend/src/app/legal/privacy/page.tsx @@ -46,8 +46,8 @@ export default function PrivacyPolicyPage() {
  • {section.title} @@ -59,27 +59,27 @@ export default function PrivacyPolicyPage() { {/* Main Content */}
    -

    Privacy Policy

    +

    Privacy Policy

    Effective Date: {effectiveDate}

    -
    +

    Website:{" "} - + {websiteUrl}

    Responsible Party: {orgName}

    Email:{" "} - + {orgEmail}

    -

    🟢 Plain English Summary

    +

    🟢 Plain English Summary

    We respect your privacy — here's what you need to know:

    • We only collect info needed to register you for events and process payments.
    • @@ -210,7 +210,7 @@ function Section({ return (
      -

      {title}

      +

      {title}

      {content[id]?.map((line, i) => { // Render email addresses as links const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/); @@ -220,7 +220,7 @@ function Section({ return (

      {parts[0]} - + {email} {parts[1] || ""} @@ -231,7 +231,7 @@ function Section({ return (

      {line.replace("www.inforeg.org.za", "")} - + www.inforeg.org.za

      diff --git a/frontend/src/app/legal/terms/page.tsx b/frontend/src/app/legal/terms/page.tsx index 6b50f17..a27661b 100644 --- a/frontend/src/app/legal/terms/page.tsx +++ b/frontend/src/app/legal/terms/page.tsx @@ -44,8 +44,8 @@ export default function TermsOfUsePage() {
    • {section.title} @@ -57,13 +57,13 @@ export default function TermsOfUsePage() { {/* Main Content */}
      -

      Terms of Use

      +

      Terms of Use

      Effective Date: {effectiveDate}

      -
      +

      Website:{" "} - + {websiteUrl}

      @@ -71,7 +71,7 @@ export default function TermsOfUsePage() {
      -

      🟢 Plain English Summary

      +

      🟢 Plain English Summary

      Welcome to {orgName}! This site helps you book and pay for events hosted or supported by {orgName}.

      By using it, you agree to:

        @@ -188,7 +188,7 @@ function Section({ return (
        -

        {title}

        +

        {title}

        {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 (

        {parts[0]} - + {email} {parts[1] || ""} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 531d26b..b923672 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,10 +1,8 @@ import { EventCard } from "@/components/events/EventCard"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; -import { SectionHeader } from "@/components/shared/SectionHeader"; -import { ContactSection } from "@/components/shared/ContactSection"; import { appName } from "@/lib/siteConfig"; - +import { Calendar, UserPlus, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react"; type Event = { id: string; @@ -20,6 +18,13 @@ type Event = { import { apiFetch } from "@/lib/api"; +const FEATURES = [ + { icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." }, + { icon: Calendar, title: "Easy Registration", description: "Simple and secure registration so you can focus on what matters." }, + { icon: Users, title: "Community", description: "Connect with others and grow in a supportive and loving community." }, + { icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." }, +]; + export default async function HomePage() { const events = await apiFetch("/api/events", { nextOptions: { next: { revalidate: 60 } } }); const now = Date.now(); @@ -34,34 +39,87 @@ export default async function HomePage() {

        -
        -

        Welcome to {appName}

        -

        Experience unforgettable moments. Powered by purpose.

        -
        - - View Events - - - Join Us - +
        +
        - -
        - +
        +
        + {FEATURES.map(f => { + const Icon = f.icon; + return ( +
        +
        + +
        +

        {f.title}

        +

        {f.description}

        +
        + ); + })} +
        +
        + +
        +
        +
        + +
        +

        Secure & reliable

        +

        Your details and payments are handled securely from registration through to the day of the event.

        +
        +