From 047f61b6272775dbe8af21cdb45451d9d98c7653 Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 28 Jul 2026 14:21:47 +0200 Subject: [PATCH 1/4] Fix self-service kiosk: hide closed events and autofill known visitors - GET /api/events/all gains an opt-in excludeClosed=true param, used only by the kiosk, so closed events no longer show as selectable there while other admin/supervisor screens that still need to see closed events are unaffected. - GET /api/users/check-exists now also returns the matched account's name, email, phone, and notification preference (safe fields only). The kiosk's existing debounced lookup uses this to autofill whichever fields are still blank when a visitor enters an email or phone that matches an existing account, without overwriting anything already typed. --- backend/src/controllers/eventController.js | 2 ++ backend/src/controllers/userController.js | 17 ++++++++++++--- frontend/src/app/self-service/page.tsx | 25 ++++++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) 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..32a5a27 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -180,7 +180,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); @@ -431,7 +431,11 @@ export default function SelfServicePage() { }; }, []); - // ─── Check whether an account already exists for the entered email/phone ── + // ─── Check whether an account already exists for the entered email/phone, and + // autofill the rest of the visitor's details from it (name, the other contact + // channel, notification preference) so staff don't have to re-ask for info + // already on file. Only fills fields that are still blank — never overwrites + // whatever the operator is actively typing. ── useEffect(() => { const email = visitorEmail.trim(); const phone = visitorPhone.trim(); @@ -445,11 +449,18 @@ export default function SelfServicePage() { 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 } - ); + 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 }); setAccountExists(!!data?.exists); + if (data?.user) { + const found = data.user; + setVisitorName((prev) => (prev.trim() ? prev : found.name)); + if (found.email && !email) setVisitorEmail(found.email); + if (found.phoneNumber && !phone) setVisitorPhone(found.phoneNumber); + setNotificationPref(found.notificationPreference); + } } catch { setAccountExists(false); } finally { @@ -795,7 +806,7 @@ export default function SelfServicePage() { {/* Account creation toggle — hidden once we know an account already exists */} {accountExists ? (
- An account with this email/number already exists — you'll be registered using that account. + An account with this email/number already exists — we've filled in their details below, and you'll be registered using that account.
) : (
From 5167706d1b53af81ace2ef414e230fd204441c0e Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 28 Jul 2026 14:24:21 +0200 Subject: [PATCH 2/4] Update changelog for self-service kiosk fixes --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51202d5..ff8f767 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: entering an email or phone number that matches an existing account now autofills the visitor's name, the other contact channel, and notification preference, instead of requiring staff to re-enter details already on file. + ## [1.3.0] - 2026-07-27 ### Added From b4cd14016869ac34aa80d91c2210c1851e96a69a Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 28 Jul 2026 14:35:28 +0200 Subject: [PATCH 3/4] Replace as-you-type account check with an explicit search field The kiosk previously ran a debounced background lookup on every keystroke in the visitor Email/Phone fields, silently autofilling matches. Replaced with a dedicated "Look up existing account" field that only searches when the operator presses Enter or clicks Search, and matches exactly against either email or phone (never both from one query, to avoid digits in an email being misread as an unrelated phone number). Also simplified the "account already exists" banner wording. --- CHANGELOG.md | 2 +- frontend/src/app/self-service/page.tsx | 121 ++++++++++++++++--------- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8f767..286655a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project follows [Semantic Versioning](https://semver.org/). ### Fixed - Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable. -- Self-service kiosk: entering an email or phone number that matches an existing account now autofills the visitor's name, the other contact channel, and notification preference, instead of requiring staff to re-enter details already on file. +- 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 diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index 32a5a27..45af3c9 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -113,8 +113,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); @@ -416,6 +420,8 @@ export default function SelfServicePage() { setCreateAccount(false); setVisitorPassword(""); setAccountExists(false); + setLookupQuery(""); + setLookupMessage(null); setFormError(null); setCurrentRegistrationId(null); setCurrentUserId(null); @@ -431,44 +437,51 @@ export default function SelfServicePage() { }; }, []); - // ─── Check whether an account already exists for the entered email/phone, and - // autofill the rest of the visitor's details from it (name, the other contact - // channel, notification preference) so staff don't have to re-ask for info - // already on file. Only fills fields that are still blank — never overwrites - // whatever the operator is actively typing. ── - 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; - user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null; - }>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken }); - setAccountExists(!!data?.exists); - if (data?.user) { - const found = data.user; - setVisitorName((prev) => (prev.trim() ? prev : found.name)); - if (found.email && !email) setVisitorEmail(found.email); - if (found.phoneNumber && !phone) setVisitorPhone(found.phoneNumber); - setNotificationPref(found.notificationPreference); - } - } 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 params = new URLSearchParams(); + if (q.includes("@")) 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 { setAccountExists(false); - } finally { - setCheckingAccount(false); + 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(() => { @@ -639,6 +652,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} +

+ )} +
+
@@ -806,7 +847,7 @@ export default function SelfServicePage() { {/* Account creation toggle — hidden once we know an account already exists */} {accountExists ? (
- An account with this email/number already exists — we've filled in their details below, and you'll be registered using that account. + An account with this email/number already exists — you'll be registered using that account.
) : (
@@ -819,9 +860,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 && ( From 21176e1e0b222aca2cfe651c114d6c5f6c161bae Mon Sep 17 00:00:00 2001 From: joshua Date: Tue, 28 Jul 2026 16:05:41 +0200 Subject: [PATCH 4/4] Populate email/phone from an unmatched kiosk search query When the account lookup finds no match, carry the typed query into whichever of Email/Phone it resembles (email-shaped or phone-shaped) instead of discarding it, so the operator doesn't have to retype it. Leaves both blank if it matches neither shape, and resets Name/notification preference for a fresh entry. --- frontend/src/app/self-service/page.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index 45af3c9..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"); @@ -450,8 +457,9 @@ export default function SelfServicePage() { // 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 (q.includes("@")) params.set("email", q); + if (isEmailLike) params.set("email", q); else params.set("phone", q); const data = await apiFetch<{ exists: boolean; @@ -466,7 +474,21 @@ export default function SelfServicePage() { 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); + setVisitorName(""); + setNotificationPref("email"); + if (isEmailLike) { + setVisitorEmail(q); + setVisitorPhone(""); + } else if (looksLikePhone(q)) { + setVisitorPhone(q); + setVisitorEmail(""); + } else { + setVisitorEmail(""); + setVisitorPhone(""); + } setLookupMessage("No matching account found."); } } catch {