Compare commits

...
Author SHA1 Message Date
joshua 8a75c9155b Bump version to 1.3.1 2026-07-28 16:17:37 +02:00
joshua 8850984055 Merge branch 'fix/self-service-kiosk' 2026-07-28 16:16:31 +02:00
joshua 21176e1e0b 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.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 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.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 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.
2026-07-28 14:21:47 +02:00
7 changed files with 133 additions and 38 deletions
+10 -1
View File
@@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [1.3.1] - 2026-07-28
### 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.
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
## [1.3.0] - 2026-07-27 ## [1.3.0] - 2026-07-27
### Added ### Added
@@ -69,7 +77,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend). - Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main [Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0 [1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0 [1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0 [1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.3.0", "version": "1.3.1",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -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) });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
+102 -30
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 —
const params = new URLSearchParams(); // sending the same raw string as both could let digits embedded in an email
if (email) params.set("email", email); // (e.g. a numeric local-part) get misread as an unrelated phone number.
if (phone) params.set("phone", phone); const isEmailLike = q.includes("@");
const data = await apiFetch<{ exists: boolean }>( const params = new URLSearchParams();
`/api/users/check-exists?${params.toString()}`, if (isEmailLike) params.set("email", q);
{ authToken: supervisorToken } else params.set("phone", q);
); const data = await apiFetch<{
setAccountExists(!!data?.exists); exists: boolean;
} catch { 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); 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.");
} }
}, 400); } catch {
return () => clearTimeout(handle); setLookupMessage("Lookup failed. Please try again.");
}, [visitorEmail, visitorPhone, supervisorToken]); } finally {
setLookupLoading(false);
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
}
// 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 && (
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.3.0", "version": "1.3.1",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",
@@ -8,7 +8,8 @@
"start:backend": "cd backend && npm run start", "start:backend": "cd backend && npm run start",
"start:frontend": "cd frontend && npm run start", "start:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"" "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",