|
|
|
@@ -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<Screen>("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<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);
|
|
|
|
|
|
|
|
|
@@ -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<HTMLInputElement>) {
|
|
|
|
|
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() {
|
|
|
|
|
<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'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>
|
|
|
|
@@ -808,9 +882,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 && (
|
|
|
|
|