Merge branch 'fix/self-service-kiosk'

This commit is contained in:
2026-07-28 16:16:31 +02:00
4 changed files with 123 additions and 33 deletions
+5
View File
@@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [1.3.0] - 2026-07-27
### Added ### Added
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
try { try {
const includePast = req.query.includePast === 'true'; const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true'; const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {}; const where = {};
if (!includeInactive) where.isActive = true; if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() }; if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function'); const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
+14 -3
View File
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses }, 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({ res.json({
exists: !!existingUser, exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'), hasEmail,
hasPhone: !!existingUser?.phoneNumber, 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) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+100 -28
View File
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`; 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 ─────────────────────────────────────────────────────────────── // ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() { export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup"); const [screen, setScreen] = useState<Screen>("setup");
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
const [createAccount, setCreateAccount] = useState(false); const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState(""); const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false); const [accountExists, setAccountExists] = useState(false);
const [checkingAccount, setCheckingAccount] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({}); const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ──────────────────
const [lookupQuery, setLookupQuery] = useState("");
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
setEventsLoading(true); setEventsLoading(true);
try { try {
const data: KioskEvent[] = await apiFetch( const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false", "/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token } { authToken: token }
); );
setEvents(data); setEvents(data);
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
setCreateAccount(false); setCreateAccount(false);
setVisitorPassword(""); setVisitorPassword("");
setAccountExists(false); setAccountExists(false);
setLookupQuery("");
setLookupMessage(null);
setFormError(null); setFormError(null);
setCurrentRegistrationId(null); setCurrentRegistrationId(null);
setCurrentUserId(null); setCurrentUserId(null);
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
}; };
}, []); }, []);
// ─── Check whether an account already exists for the entered email/phone ── // ─── Explicit account lookup — only runs when the operator submits the search
useEffect(() => { // (Enter or the Search button), never on keystroke. Matches exactly against a
const email = visitorEmail.trim(); // single email or phone value and only ever returns that one matched account
const phone = visitorPhone.trim(); // (or nothing) — never a broader/fuzzy match. ──
if (!supervisorToken || (!email && !phone)) { async function handleLookup() {
setAccountExists(false); const q = lookupQuery.trim();
return; if (!q || !supervisorToken) return;
} setLookupLoading(true);
const handle = setTimeout(async () => { setLookupMessage(null);
setCheckingAccount(true);
try { 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(); const params = new URLSearchParams();
if (email) params.set("email", email); if (isEmailLike) params.set("email", q);
if (phone) params.set("phone", phone); else params.set("phone", q);
const data = await apiFetch<{ exists: boolean }>( const data = await apiFetch<{
`/api/users/check-exists?${params.toString()}`, exists: boolean;
{ authToken: supervisorToken } 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?.exists && data.user) {
} catch { 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); setAccountExists(false);
} finally { setVisitorName("");
setCheckingAccount(false); setNotificationPref("email");
if (isEmailLike) {
setVisitorEmail(q);
setVisitorPhone("");
} else if (looksLikePhone(q)) {
setVisitorPhone(q);
setVisitorEmail("");
} else {
setVisitorEmail("");
setVisitorPhone("");
}
setLookupMessage("No matching account found.");
}
} catch {
setLookupMessage("Lookup failed. Please try again.");
} finally {
setLookupLoading(false);
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
} }
}, 400);
return () => clearTimeout(handle);
}, [visitorEmail, visitorPhone, supervisorToken]);
// Existing accounts are linked automatically — never show the "create account" toggle for them // Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => { useEffect(() => {
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p> <p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div> </div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor&apos;s details.</p>
<div className="flex gap-2">
<input
type="text"
value={lookupQuery}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !lookupQuery.trim()}
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
>
{lookupLoading ? "Searching…" : "Search"}
</button>
</div>
{lookupMessage && (
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
</div>
<form onSubmit={handleRegister} className="space-y-5"> <form onSubmit={handleRegister} className="space-y-5">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label> <label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
@@ -808,9 +882,7 @@ export default function SelfServicePage() {
</div> </div>
<div> <div>
<p className="font-medium text-gray-800">Create an account</p> <p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Save your details for future events</p>
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
</div> </div>
</label> </label>
{createAccount && ( {createAccount && (