Files
hope-events/frontend/src/app/self-service/page.tsx
T
joshuaandClaude Sonnet 5 b081ed3c8b Rework self-service kiosk account lookup for privacy and safety (1.3.2)
Replaces the explicit "look up existing account" search field with automatic
lookup as email/phone are entered, requires operator confirmation before any
matched account's name/email/phone/preference is changed, adds a password
show/hide toggle, and fixes two bugs found during testing: entering a phone
number belonging to a different account could silently overwrite the form
with that account's details, and re-checking an unchanged field (e.g. from
tapping a ticket quantity button) could revert edits already made. Also adds
a server-side check rejecting registrations whose email and phone resolve to
two different existing accounts, as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 12:56:26 +02:00

1222 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number | null };
type EventOption = {
id: string;
name: string;
price: number;
isMainTicket?: boolean;
earlyBirdTiers?: EarlyBirdTier[];
variants?: OptionVariant[];
};
type FormField = {
id: string;
type: string;
label: string;
isRequired: boolean;
order: number;
helpText?: string | null;
options?: any;
};
type EventForm = {
id: string;
isRequired: boolean;
fields: FormField[];
};
type KioskEvent = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
picture?: string;
eventOptions?: EventOption[];
form?: EventForm | null;
};
type Screen = "setup" | "form" | "eventform" | "confirmation";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function effectiveUnit(opt: EventOption): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter(t => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => now < t.deadline)
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function effectiveVariantUnit(opt: EventOption, variant: OptionVariant): number {
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
const variantTiers = allTiers.filter(t => t.variantId === variant.id);
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter(t => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t) => now < t.deadline)
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function fmtDate(dateStr: string) {
try {
return new Date(dateStr).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return dateStr;
}
}
function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
}
function prefLabel(pref: "email" | "whatsapp" | "both") {
return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
}
// 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;
}
// Show/hide toggle rendered inside a password input — absolutely positioned on its
// right edge, so callers must wrap the input in a `relative` container and add
// enough right padding (`pr-12`) for it not to overlap the typed text.
function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
return (
<button
type="button"
onClick={onToggle}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
aria-label={shown ? "Hide password" : "Show password"}
>
{shown ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</button>
);
}
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup");
// ── Setup state ─────────────────────────────────────────────────
const [setupEmail, setSetupEmail] = useState("");
const [setupPassword, setSetupPassword] = useState("");
const [showSetupPassword, setShowSetupPassword] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [events, setEvents] = useState<KioskEvent[]>([]);
const [selectedEventId, setSelectedEventId] = useState("");
const [eventsLoading, setEventsLoading] = useState(false);
// ── Change-event modal ───────────────────────────────────────────
const [showChangeModal, setShowChangeModal] = useState(false);
const [changePassword, setChangePassword] = useState("");
const [showChangePassword, setShowChangePassword] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null);
const [changeLoading, setChangeLoading] = useState(false);
// ── Visitor form state ───────────────────────────────────────────
const [visitorName, setVisitorName] = useState("");
const [visitorEmail, setVisitorEmail] = useState("");
const [visitorPhone, setVisitorPhone] = useState("");
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState("");
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (automatic, triggered when email/phone is entered) ──
// Email and phone are resolved to an existing account independently. If they
// resolve to the SAME account, that account is "matched". If they resolve to
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
// of silently overwriting one field's details with the other's account.
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
const accountExists = !!matchedAccount;
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// ── Event form (attendee form) state ────────────────────────────
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
const [formSubmitLoading, setFormSubmitLoading] = useState(false);
const [formSubmitError, setFormSubmitError] = useState<string | null>(null);
// ── Confirmation state ───────────────────────────────────────────
const [confirmationName, setConfirmationName] = useState("");
const [registrationIsFree, setRegistrationIsFree] = useState(false);
const [countdown, setCountdown] = useState(10);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ─── Derived ────────────────────────────────────────────────────
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) || null,
[events, selectedEventId]
);
const total = useMemo(() => {
if (!selectedEvent) return 0;
return (selectedEvent.eventOptions || []).reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveUnit(o);
}, 0);
}, [selectedEvent, quantities]);
// Does the selected event have a required form?
const eventHasRequiredForm = useMemo(() => {
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement'));
}, [selectedEvent]);
// Answerable form fields (exclude statement-type display fields)
const answerableFields = useMemo(() => {
if (!selectedEvent?.form) return [];
return (selectedEvent.form.fields || [])
.filter(f => f.type !== 'statement')
.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [selectedEvent]);
// Number of main tickets selected (for form count)
const mainTicketCount = useMemo(() => {
if (!selectedEvent) return 0;
return (selectedEvent.eventOptions || [])
.filter(o => o.isMainTicket)
.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0), 0);
}
return sum + (quantities[o.id] || 0);
}, 0);
}, [selectedEvent, quantities]);
// Differences between what's on file for the matched account and what's currently
// typed in the form — shown to the operator for confirmation before anything is saved.
const pendingChanges = useMemo(() => {
if (!matchedAccount) return [];
const changes: { field: string; from: string; to: string }[] = [];
const name = visitorName.trim();
const email = visitorEmail.trim();
const phone = visitorPhone.trim();
if (name && name !== matchedAccount.name) {
changes.push({ field: "Name", from: matchedAccount.name, to: name });
}
if (email && email !== (matchedAccount.email || "")) {
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
}
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
}
if (notificationPref !== matchedAccount.notificationPreference) {
changes.push({
field: "Send tickets via",
from: prefLabel(matchedAccount.notificationPreference),
to: prefLabel(notificationPref),
});
}
return changes;
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
// ─── Load events after supervisor login ─────────────────────────
const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true);
try {
const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token }
);
setEvents(data);
} catch {
setEvents([]);
} finally {
setEventsLoading(false);
}
}, []);
// ─── Setup: supervisor login ─────────────────────────────────────
async function handleSetupLogin(e: React.FormEvent) {
e.preventDefault();
setSetupError(null);
setSetupLoading(true);
try {
const data = await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setSetupError("Only admins and supervisors can set up the kiosk.");
setSetupLoading(false);
return;
}
setSupervisorToken(data.token);
await loadEvents(data.token);
} catch (err: any) {
setSetupError(err?.message || "Login failed. Please check your credentials.");
} finally {
setSetupLoading(false);
}
}
function handleStartKiosk() {
if (!selectedEventId || !selectedEvent) return;
const initial: Record<string, number> = {};
(selectedEvent.eventOptions || []).forEach((o) => {
if ((o.variants || []).length > 0) {
(o.variants || []).forEach((v) => (initial[`${o.id}::${v.id}`] = 0));
} else {
initial[o.id] = 0;
}
});
setQuantities(initial);
setScreen("form");
}
// ─── Change event modal ──────────────────────────────────────────
async function handleChangeEvent(e: React.FormEvent) {
e.preventDefault();
setChangeError(null);
setChangeLoading(true);
try {
const data = await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: changePassword },
});
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setChangeError("Only admins and supervisors can change the event.");
setChangeLoading(false);
return;
}
setSupervisorToken(data.token);
await loadEvents(data.token);
setChangePassword("");
setChangeError(null);
setShowChangeModal(false);
setScreen("setup");
setSelectedEventId("");
resetForm();
} catch (err: any) {
setChangeError(err?.message || "Incorrect password.");
} finally {
setChangeLoading(false);
}
}
// ─── Visitor registration ────────────────────────────────────────
// Validates the form and, if the typed name/email/phone differ from the matched
// account's details, shows a confirmation modal before anything is saved.
function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
setFormError("Name and at least an email or phone number are required.");
return;
}
if (createAccount && visitorPassword.trim().length < 6) {
setFormError("Password must be at least 6 characters.");
return;
}
if (!selectedEventId) {
setFormError("No event selected.");
return;
}
if (accountConflict) {
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
return;
}
if (pendingChanges.length > 0) {
setShowUpdateConfirm(true);
return;
}
handleRegister();
}
async function handleRegister() {
setFormLoading(true);
try {
const payload: any = {
eventId: selectedEventId,
options: Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
}),
user: {
name: visitorName.trim(),
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
},
guestOnly: !createAccount,
notificationPreference: notificationPref,
};
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
}
const result: any = await apiFetch("/api/registrations/manual", {
method: "POST",
body: payload,
authToken: supervisorToken,
});
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || total === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
if (eventHasRequiredForm && mainTicketCount > 0 && result?.id) {
setCurrentTicketIndex(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
setFormSubmitError(null);
setScreen("eventform");
} else {
setScreen("confirmation");
startCountdown();
}
} catch (err: any) {
setFormError(err?.message || "Registration failed. Please try again.");
} finally {
setFormLoading(false);
}
}
// ─── Event form submission (per-ticket) ──────────────────────────
async function handleFormSubmit(e: React.FormEvent) {
e.preventDefault();
setFormSubmitError(null);
// Validate required fields for current ticket
for (const field of answerableFields) {
if (field.isRequired && !currentTicketAnswers[field.id]?.trim()) {
setFormSubmitError(`"${field.label}" is required.`);
return;
}
}
if (!currentRegistrationId) {
setFormSubmitError("No registration found.");
return;
}
const collectedAnswers = [...allFormAnswers, currentTicketAnswers];
// If more tickets remain, advance to the next one
if (currentTicketIndex < mainTicketCount - 1) {
setAllFormAnswers(collectedAnswers);
setCurrentTicketAnswers({});
setCurrentTicketIndex((i) => i + 1);
return;
}
// Last ticket — submit all responses
setFormSubmitLoading(true);
try {
const responses = collectedAnswers.map((answers) => ({ answers }));
await apiFetch(`/api/registrations/${currentRegistrationId}/forms/responses`, {
method: "POST",
body: { responses },
authToken: supervisorToken,
});
setScreen("confirmation");
startCountdown();
} catch (err: any) {
setFormSubmitError(err?.message || "Form submission failed. Please try again.");
} finally {
setFormSubmitLoading(false);
}
}
// ─── Confirmation countdown ──────────────────────────────────────
function startCountdown() {
setCountdown(10);
if (countdownRef.current) clearInterval(countdownRef.current);
countdownRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(countdownRef.current!);
resetAndGoToForm();
return 0;
}
return c - 1;
});
}, 1000);
}
function resetAndGoToForm() {
if (countdownRef.current) clearInterval(countdownRef.current);
resetForm();
setScreen("form");
const initial: Record<string, number> = {};
(selectedEvent?.eventOptions || []).forEach((o) => {
if ((o.variants || []).length > 0) {
(o.variants || []).forEach((v) => (initial[`${o.id}::${v.id}`] = 0));
} else {
initial[o.id] = 0;
}
});
setQuantities(initial);
}
function resetForm() {
setVisitorName("");
setVisitorEmail("");
setVisitorPhone("");
setNotificationPref("email");
setCreateAccount(false);
setVisitorPassword("");
setShowVisitorPassword(false);
setEmailMatch(null);
setPhoneMatch(null);
appliedMatchIdRef.current = null;
setShowUpdateConfirm(false);
setLookupMessage(null);
setFormError(null);
setCurrentRegistrationId(null);
setCurrentUserId(null);
setCurrentTicketIndex(0);
setAllFormAnswers([]);
setCurrentTicketAnswers({});
setFormSubmitError(null);
}
useEffect(() => {
return () => {
if (countdownRef.current) clearInterval(countdownRef.current);
};
}, []);
// ─── Automatic account lookup — runs once the operator finishes entering the
// email or phone field (on blur), never on keystroke. Matches exactly against
// that one value and only ever returns that one matched account (or nothing) —
// never a broader/fuzzy match. Email and phone are tracked as two independent
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
// "last found account" — that's what previously let changing the phone number
// silently pull in and overwrite the form with an unrelated account's details. ──
async function performLookup(kind: "email" | "phone", rawValue: string) {
const value = rawValue.trim();
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
if (!value || !supervisorToken) { setMatch(null); return; }
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
setLookupLoading(true);
setLookupMessage(null);
try {
const params = new URLSearchParams();
if (kind === "email") params.set("email", value);
else params.set("phone", value);
const data = await apiFetch<{
exists: boolean;
user?: MatchedAccount | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
if (data?.exists && data.user) {
setMatch(data.user);
setLookupMessage("Account found — details filled in below.");
} else {
setMatch(null);
setLookupMessage(null);
}
} catch {
setLookupMessage("Lookup failed. Please try again.");
} finally {
setLookupLoading(false);
}
}
function handleEmailBlur() {
performLookup("email", visitorEmail);
}
function handlePhoneBlur() {
performLookup("phone", visitorPhone);
}
// Autofill Name/Email/Phone/preference from the matched account — but only once per
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
// quantity button) blurs whatever field was last active and re-runs its lookup;
// that returns the same account as a new object each time, and keying this off
// object identity instead of id made it re-fire and stomp every field — including
// ones the operator had already deliberately edited — back to the account's
// original values on every unrelated tap.
const appliedMatchIdRef = useRef<string | null>(null);
useEffect(() => {
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
appliedMatchIdRef.current = matchedAccount.id;
setVisitorName(matchedAccount.name);
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
setNotificationPref(matchedAccount.notificationPreference);
}, [matchedAccount]);
// Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => {
if (accountExists && createAccount) {
setCreateAccount(false);
setVisitorPassword("");
}
}, [accountExists, createAccount]);
// ─── Setup screen ────────────────────────────────────────────────
const isLoggedIn = !!supervisorToken;
// ─── Render ──────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Change Event button */}
{screen === "form" && (
<div className="fixed top-3 right-3 z-50">
<button
onClick={() => { setShowChangeModal(true); setChangePassword(""); setChangeError(null); }}
className="text-xs bg-white border border-gray-300 text-gray-500 rounded px-3 py-1.5 shadow-sm hover:bg-gray-100 transition"
>
Change Event
</button>
</div>
)}
{/* ── Change Event Modal ─────────────────────────────────────── */}
{showChangeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Change Event</h2>
<p className="text-sm text-gray-500 mb-5">Re-enter your password to change the event.</p>
<form onSubmit={handleChangeEvent} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your password"
autoFocus
required
/>
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
</div>
</div>
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={() => setShowChangeModal(false)}
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
>
Cancel
</button>
<button
type="submit"
disabled={changeLoading}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 disabled:opacity-60 transition"
>
{changeLoading ? "Verifying…" : "Confirm"}
</button>
</div>
</form>
</div>
</div>
)}
{/* ── Setup Screen ───────────────────────────────────────────── */}
{screen === "setup" && (
<div className="flex-1 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Self-Service Kiosk</h1>
<p className="text-gray-500 mt-2">Supervisor setup required</p>
</div>
{!isLoggedIn && (
<form onSubmit={handleSetupLogin} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="supervisor@example.com"
required
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<div className="relative">
<input
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div>
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button
type="submit"
disabled={setupLoading}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-60 transition mt-2"
>
{setupLoading ? "Signing in…" : "Sign In"}
</button>
</form>
)}
{isLoggedIn && (
<div className="space-y-5">
<p className="text-green-700 text-sm font-medium bg-green-50 border border-green-200 rounded-lg px-4 py-2">
Signed in as supervisor. Select an event to begin.
</p>
{eventsLoading ? (
<p className="text-gray-500 text-center py-4">Loading events</p>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""> Select an event </option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title} ({fmtDate(ev.startDate)})
</option>
))}
</select>
</div>
)}
{selectedEvent && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
<p className="font-semibold">{selectedEvent.title}</p>
<p className="text-blue-600">{fmtDate(selectedEvent.startDate)} {fmtDate(selectedEvent.endDate)}</p>
{selectedEvent.form?.isRequired && (
<p className="text-orange-600 mt-1 text-xs font-medium">This event requires an attendee form to be completed at registration.</p>
)}
</div>
)}
<button
onClick={handleStartKiosk}
disabled={!selectedEventId}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-50 transition"
>
Start Kiosk
</button>
</div>
)}
</div>
</div>
)}
{/* ── Registration Form Screen ───────────────────────────────── */}
{screen === "form" && selectedEvent && (
<div className="flex-1 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg p-8">
<div className="text-center mb-7">
<p className="text-sm text-gray-500 uppercase tracking-wide font-medium">Registering for</p>
<h2 className="text-2xl font-bold text-gray-900 mt-1">{selectedEvent.title}</h2>
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div>
<form onSubmit={handleRegisterSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
{!visitorPhone.trim() && <span className="text-red-500"> *</span>}
{visitorPhone.trim() && <span className="text-gray-400 font-normal"> (optional if phone provided)</span>}
</label>
<input
type="email"
value={visitorEmail}
onChange={(e) => {
const v = e.target.value;
setVisitorEmail(v);
// Auto-derive preference when only one contact channel is present
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
}}
onBlur={handleEmailBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="john@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cell Number
{!visitorEmail.trim() && <span className="text-red-500"> *</span>}
{visitorEmail.trim() && <span className="text-gray-400 font-normal"> (optional if email provided)</span>}
</label>
<input
type="tel"
value={visitorPhone}
onChange={(e) => {
const v = e.target.value;
setVisitorPhone(v);
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
}}
onBlur={handlePhoneBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="+27 82 000 0000"
/>
</div>
{lookupLoading && (
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account</p>
)}
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
one of these fields before continuing.
</div>
)}
{!lookupLoading && !accountConflict && lookupMessage && (
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Smith"
required
/>
</div>
{/* Preference selector — only shown when both channels are available */}
{visitorEmail.trim() && visitorPhone.trim() && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Send tickets via</label>
<div className="flex rounded-xl border border-gray-200 overflow-hidden text-sm font-medium">
{(["email", "whatsapp", "both"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setNotificationPref(p)}
className={`flex-1 py-3 transition-colors ${
notificationPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
: p === "both" ? "bg-indigo-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{p === "email" ? "Email" : p === "whatsapp" ? "WhatsApp" : "Both"}
</button>
))}
</div>
</div>
)}
{/* Ticket options */}
{(selectedEvent.eventOptions || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
<div className="space-y-3">
{(selectedEvent.eventOptions || []).map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
return (
<div key={opt.id} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-gray-50 border-b border-gray-200">
<p className="font-medium text-gray-800 text-sm">{opt.name}</p>
</div>
<div className="divide-y divide-gray-100">
{(opt.variants || []).map((v) => {
const unit = effectiveVariantUnit(opt, v);
const baseVariantPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-gray-800">{v.name}</p>
<p className="text-sm text-gray-500">
{fmtCurrency(unit)}
{unit !== baseVariantPrice && baseVariantPrice > 0 ? ` (was ${fmtCurrency(baseVariantPrice)})` : ""}
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: Math.max(0, (q[key] || 0) - 1) }))}
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
>
</button>
<span className="w-8 text-center text-lg font-semibold">{quantities[key] || 0}</span>
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
>
+
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
const unit = effectiveUnit(opt);
return (
<div key={opt.id} className="flex items-center justify-between border border-gray-200 rounded-xl px-4 py-3.5">
<div>
<p className="font-medium text-gray-800">{opt.name}</p>
<p className="text-sm text-gray-500">{fmtCurrency(unit)}{unit !== opt.price && opt.price > 0 ? ` (was ${fmtCurrency(opt.price)})` : ""}</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: Math.max(0, (q[opt.id] || 0) - 1) }))}
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
>
</button>
<span className="w-8 text-center text-lg font-semibold">{quantities[opt.id] || 0}</span>
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
>
+
</button>
</div>
</div>
);
})}
</div>
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
Total: {fmtCurrency(total)}
</div>
)}
</div>
)}
{/* 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 you&apos;ll be registered using that account.
</div>
) : (
<div className="border border-gray-200 rounded-xl px-4 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setCreateAccount((v) => !v)}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-blue-600" : "bg-gray-300"}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
</div>
<div>
<p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500">Save your details for future events</p>
</div>
</label>
{createAccount && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<div className="relative">
<input
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
</div>
</div>
)}
</div>
)}
{formError && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
{formError}
</div>
)}
<button
type="submit"
disabled={formLoading || accountConflict}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
>
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
</button>
</form>
</div>
</div>
)}
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
{showUpdateConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
<p className="text-sm text-gray-500 mb-5">
These details differ from what&apos;s on file for this account. Confirm to update them.
</p>
<div className="space-y-3 mb-6">
{pendingChanges.map((c) => (
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
<p className="text-sm text-gray-400 line-through">{c.from}</p>
<p className="text-sm text-green-700 font-medium">{c.to}</p>
</div>
))}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowUpdateConfirm(false)}
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
>
Cancel
</button>
<button
type="button"
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
>
Confirm &amp; Update
</button>
</div>
</div>
</div>
)}
{/* ── Event Form Screen ──────────────────────────────────────── */}
{screen === "eventform" && selectedEvent?.form && (
<div className="flex-1 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg p-8">
<div className="text-center mb-6">
<p className="text-sm text-gray-500 uppercase tracking-wide font-medium">{selectedEvent.title}</p>
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
{mainTicketCount > 1 && (
<div className="mt-3">
<p className="text-base font-semibold text-blue-700">
Attendee {currentTicketIndex + 1} of {mainTicketCount}
</p>
<div className="flex gap-1.5 justify-center mt-2">
{Array.from({ length: mainTicketCount }).map((_, i) => (
<div
key={i}
className={`h-2 rounded-full transition-all ${
i < currentTicketIndex
? "w-6 bg-green-500"
: i === currentTicketIndex
? "w-6 bg-blue-600"
: "w-6 bg-gray-200"
}`}
/>
))}
</div>
</div>
)}
{mainTicketCount <= 1 && (
<p className="text-sm text-gray-500 mt-1">This form is required to complete your registration.</p>
)}
</div>
<form onSubmit={handleFormSubmit} className="space-y-5">
{answerableFields.map((field) => (
<div key={field.id}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{field.label}
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{field.helpText && (
<p className="text-xs text-gray-500 mb-1">{field.helpText}</p>
)}
{field.type === "yes_no" ? (
<div className="flex gap-4">
{["Yes", "No"].map((opt) => (
<label key={opt} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={field.id}
value={opt}
checked={currentTicketAnswers[field.id] === opt}
onChange={() => setCurrentTicketAnswers(a => ({ ...a, [field.id]: opt }))}
required={field.isRequired && !currentTicketAnswers[field.id]}
className="accent-blue-600"
/>
<span className="text-base">{opt}</span>
</label>
))}
</div>
) : field.type === "numeric" ? (
<input
type="number"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : field.type === "date" ? (
<input
type="date"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : field.type === "paragraph" ? (
<textarea
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
rows={3}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
) : (
<input
type="text"
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)}
</div>
))}
{formSubmitError && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
{formSubmitError}
</div>
)}
<button
type="submit"
disabled={formSubmitLoading}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
>
{formSubmitLoading
? "Submitting…"
: currentTicketIndex < mainTicketCount - 1
? `Next — Attendee ${currentTicketIndex + 2} of ${mainTicketCount}`
: "Complete Registration"}
</button>
</form>
</div>
</div>
)}
{/* ── Confirmation Screen ────────────────────────────────────── */}
{screen === "confirmation" && (
<div className="flex-1 flex items-center justify-center p-6">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-10 text-center">
<div className="mx-auto mb-6 w-20 h-20 bg-green-100 rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">You&apos;re Registered!</h2>
{confirmationName && (
<p className="text-lg text-gray-600 mb-3">Welcome, <span className="font-semibold">{confirmationName}</span>!</p>
)}
{selectedEvent && (
<p className="text-blue-700 font-medium mb-4">{selectedEvent.title}</p>
)}
{registrationIsFree ? (
<div className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 text-green-800 text-sm mb-5">
<p className="font-semibold">
{notificationPref === "whatsapp"
? "Your tickets have been sent to your WhatsApp."
: notificationPref === "both"
? "Your tickets have been sent via email and WhatsApp."
: "Your tickets have been emailed to you."}
</p>
</div>
) : (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-blue-800 text-sm mb-5 text-left">
<p className="font-semibold mb-1">
{notificationPref === "whatsapp"
? "Payment instructions have been sent to your WhatsApp."
: notificationPref === "both"
? "Payment instructions have been sent via email and WhatsApp."
: "Payment instructions have been emailed to you."}
</p>
<p className="text-blue-700">Payment can also be made at the door cash and card accepted.</p>
</div>
)}
<div className="mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-blue-200 bg-blue-50 text-2xl font-bold text-blue-700">
{countdown}
</div>
<p className="text-sm text-gray-500 mt-2">Returning to registration in {countdown}s</p>
</div>
<button
onClick={resetAndGoToForm}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 transition"
>
OK Next Person
</button>
</div>
</div>
)}
</div>
);
}