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.
This commit is contained in:
2026-07-28 14:35:28 +02:00
parent 5167706d1b
commit b4cd140168
2 changed files with 81 additions and 42 deletions
+1 -1
View File
@@ -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
+70 -31
View File
@@ -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<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 [formError, setFormError] = useState<string | null>(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);
// ─── 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 (email) params.set("email", email);
if (phone) params.set("phone", phone);
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 });
setAccountExists(!!data?.exists);
if (data?.user) {
if (data?.exists && 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);
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setLookupMessage("Account found — details filled in below.");
} else {
setAccountExists(false);
setLookupMessage("No matching account found.");
}
} catch {
setAccountExists(false);
setLookupMessage("Lookup failed. Please try again.");
} finally {
setCheckingAccount(false);
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
useEffect(() => {
@@ -639,6 +652,34 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</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">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
@@ -806,7 +847,7 @@ export default function SelfServicePage() {
{/* Account creation toggle — hidden once we know an account already exists */}
{accountExists ? (
<div className="border border-green-200 bg-green-50 rounded-xl px-4 py-3 text-sm text-green-700">
An account with this email/number already exists we&apos;ve filled in their details below, and you&apos;ll be registered using that account.
An account with this email/number already exists you&apos;ll be registered using that account.
</div>
) : (
<div className="border border-gray-200 rounded-xl px-4 py-4">
@@ -819,9 +860,7 @@ export default function SelfServicePage() {
</div>
<div>
<p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500">
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
<p className="text-sm text-gray-500">Save your details for future events</p>
</div>
</label>
{createAccount && (