diff --git a/CHANGELOG.md b/CHANGELOG.md index 51202d5..286655a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable. +- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file. + ## [1.3.0] - 2026-07-27 ### Added diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index b6ed3ce..42eff26 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => { try { const includePast = req.query.includePast === 'true'; const includeInactive = req.query.includeInactive === 'true'; + const excludeClosed = req.query.excludeClosed === 'true'; const where = {}; if (!includeInactive) where.isActive = true; if (!includePast) where.endDate = { gte: new Date() }; + if (excludeClosed) where.cashupStatus = { not: 'closed' }; const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index a4dc3e3..9e96e7d 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => { const existingUser = await prisma.user.findFirst({ where: { OR: searchClauses }, - select: { email: true, phoneNumber: true }, + select: { name: true, email: true, phoneNumber: true, notificationPreference: true }, }); + const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local'); + const hasPhone = !!existingUser?.phoneNumber; + res.json({ exists: !!existingUser, - hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'), - hasPhone: !!existingUser?.phoneNumber, + hasEmail, + hasPhone, + // Safe-to-display fields only, for autofilling a lookup form — never the password. + // Guest placeholder emails are withheld the same way hasEmail already treats them. + user: existingUser ? { + name: existingUser.name, + email: hasEmail ? existingUser.email : null, + phoneNumber: hasPhone ? existingUser.phoneNumber : null, + notificationPreference: existingUser.notificationPreference, + } : null, }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index d56e5cd..db16625 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -85,6 +85,13 @@ function fmtCurrency(val: number) { return val === 0 ? "Free" : `R${val.toFixed(2)}`; } +// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0), +// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code). +function looksLikePhone(s: string): boolean { + const digits = s.replace(/\D/g, ""); + return digits.length >= 9 && digits.length <= 11; +} + // ─── Kiosk Page ─────────────────────────────────────────────────────────────── export default function SelfServicePage() { const [screen, setScreen] = useState("setup"); @@ -113,8 +120,12 @@ export default function SelfServicePage() { const [createAccount, setCreateAccount] = useState(false); const [visitorPassword, setVisitorPassword] = useState(""); const [accountExists, setAccountExists] = useState(false); - const [checkingAccount, setCheckingAccount] = useState(false); const [quantities, setQuantities] = useState>({}); + + // ── Account lookup (search-by-email/phone) state ────────────────── + const [lookupQuery, setLookupQuery] = useState(""); + const [lookupLoading, setLookupLoading] = useState(false); + const [lookupMessage, setLookupMessage] = useState(null); const [formLoading, setFormLoading] = useState(false); const [formError, setFormError] = useState(null); @@ -180,7 +191,7 @@ export default function SelfServicePage() { setEventsLoading(true); try { const data: KioskEvent[] = await apiFetch( - "/api/events/all?includePast=false&includeInactive=false", + "/api/events/all?includePast=false&includeInactive=false&excludeClosed=true", { authToken: token } ); setEvents(data); @@ -416,6 +427,8 @@ export default function SelfServicePage() { setCreateAccount(false); setVisitorPassword(""); setAccountExists(false); + setLookupQuery(""); + setLookupMessage(null); setFormError(null); setCurrentRegistrationId(null); setCurrentUserId(null); @@ -431,33 +444,66 @@ export default function SelfServicePage() { }; }, []); - // ─── Check whether an account already exists for the entered email/phone ── - useEffect(() => { - const email = visitorEmail.trim(); - const phone = visitorPhone.trim(); - if (!supervisorToken || (!email && !phone)) { - setAccountExists(false); - return; - } - const handle = setTimeout(async () => { - setCheckingAccount(true); - try { - const params = new URLSearchParams(); - if (email) params.set("email", email); - if (phone) params.set("phone", phone); - const data = await apiFetch<{ exists: boolean }>( - `/api/users/check-exists?${params.toString()}`, - { authToken: supervisorToken } - ); - setAccountExists(!!data?.exists); - } catch { + // ─── Explicit account lookup — only runs when the operator submits the search + // (Enter or the Search button), never on keystroke. Matches exactly against a + // single email or phone value and only ever returns that one matched account + // (or nothing) — never a broader/fuzzy match. ── + async function handleLookup() { + const q = lookupQuery.trim(); + if (!q || !supervisorToken) return; + setLookupLoading(true); + setLookupMessage(null); + try { + // Classify the query as email- or phone-shaped and send it as only that param — + // sending the same raw string as both could let digits embedded in an email + // (e.g. a numeric local-part) get misread as an unrelated phone number. + const isEmailLike = q.includes("@"); + const params = new URLSearchParams(); + if (isEmailLike) params.set("email", q); + else params.set("phone", q); + const data = await apiFetch<{ + exists: boolean; + user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null; + }>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken }); + if (data?.exists && data.user) { + const found = data.user; + setVisitorName(found.name); + setVisitorEmail(found.email || ""); + setVisitorPhone(found.phoneNumber || ""); + setNotificationPref(found.notificationPreference); + setAccountExists(true); + setLookupMessage("Account found — details filled in below."); + } else { + // No match — still save the retype: carry the query into whichever field it + // resembles, and leave everything else blank for a fresh entry. setAccountExists(false); - } finally { - setCheckingAccount(false); + setVisitorName(""); + setNotificationPref("email"); + if (isEmailLike) { + setVisitorEmail(q); + setVisitorPhone(""); + } else if (looksLikePhone(q)) { + setVisitorPhone(q); + setVisitorEmail(""); + } else { + setVisitorEmail(""); + setVisitorPhone(""); + } + setLookupMessage("No matching account found."); } - }, 400); - return () => clearTimeout(handle); - }, [visitorEmail, visitorPhone, supervisorToken]); + } catch { + setLookupMessage("Lookup failed. Please try again."); + } finally { + setLookupLoading(false); + } + } + + function handleLookupKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + handleLookup(); + } + } // Existing accounts are linked automatically — never show the "create account" toggle for them useEffect(() => { @@ -628,6 +674,34 @@ export default function SelfServicePage() {

{fmtDate(selectedEvent.startDate)}

+
+ +

Search by email or phone number to fill in a returning visitor's details.

+
+ setLookupQuery(e.target.value)} + onKeyDown={handleLookupKeyDown} + className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Email or phone number" + /> + +
+ {lookupMessage && ( +

+ {lookupMessage} +

+ )} +
+
@@ -808,9 +882,7 @@ export default function SelfServicePage() {

Create an account

-

- {checkingAccount ? "Checking for an existing account…" : "Save your details for future events"} -

+

Save your details for future events

{createAccount && (