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>
1222 lines
57 KiB
TypeScript
1222 lines
57 KiB
TypeScript
"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'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'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 & 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'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>
|
||
);
|
||
} |