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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -85,6 +85,10 @@ 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 {
|
||||
@@ -92,6 +96,32 @@ function looksLikePhone(s: string): boolean {
|
||||
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");
|
||||
@@ -99,6 +129,7 @@ export default function SelfServicePage() {
|
||||
// ── 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);
|
||||
@@ -109,6 +140,7 @@ export default function SelfServicePage() {
|
||||
// ── 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);
|
||||
|
||||
@@ -119,13 +151,23 @@ export default function SelfServicePage() {
|
||||
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [createAccount, setCreateAccount] = useState(false);
|
||||
const [visitorPassword, setVisitorPassword] = useState("");
|
||||
const [accountExists, setAccountExists] = useState(false);
|
||||
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// ── Account lookup (search-by-email/phone) state ──────────────────
|
||||
const [lookupQuery, setLookupQuery] = useState("");
|
||||
// ── 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);
|
||||
|
||||
@@ -186,6 +228,33 @@ export default function SelfServicePage() {
|
||||
}, 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);
|
||||
@@ -273,7 +342,9 @@ export default function SelfServicePage() {
|
||||
}
|
||||
|
||||
// ─── Visitor registration ────────────────────────────────────────
|
||||
async function handleRegister(e: React.FormEvent) {
|
||||
// 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())) {
|
||||
@@ -288,7 +359,18 @@ export default function SelfServicePage() {
|
||||
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 = {
|
||||
@@ -426,8 +508,11 @@ export default function SelfServicePage() {
|
||||
setNotificationPref("email");
|
||||
setCreateAccount(false);
|
||||
setVisitorPassword("");
|
||||
setAccountExists(false);
|
||||
setLookupQuery("");
|
||||
setShowVisitorPassword(false);
|
||||
setEmailMatch(null);
|
||||
setPhoneMatch(null);
|
||||
appliedMatchIdRef.current = null;
|
||||
setShowUpdateConfirm(false);
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
@@ -444,52 +529,35 @@ export default function SelfServicePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── Explicit account lookup — only runs when the operator submits the search
|
||||
// (Enter or the Search button), never on keystroke. Matches exactly against a
|
||||
// single email or phone value and only ever returns that one matched account
|
||||
// (or nothing) — never a broader/fuzzy match. ──
|
||||
async function handleLookup() {
|
||||
const q = lookupQuery.trim();
|
||||
if (!q || !supervisorToken) return;
|
||||
// ─── 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 {
|
||||
// Classify the query as email- or phone-shaped and send it as only that param —
|
||||
// sending the same raw string as both could let digits embedded in an email
|
||||
// (e.g. a numeric local-part) get misread as an unrelated phone number.
|
||||
const isEmailLike = q.includes("@");
|
||||
const params = new URLSearchParams();
|
||||
if (isEmailLike) params.set("email", q);
|
||||
else params.set("phone", q);
|
||||
if (kind === "email") params.set("email", value);
|
||||
else params.set("phone", value);
|
||||
const data = await apiFetch<{
|
||||
exists: boolean;
|
||||
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
|
||||
user?: MatchedAccount | null;
|
||||
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
|
||||
if (data?.exists && data.user) {
|
||||
const found = data.user;
|
||||
setVisitorName(found.name);
|
||||
setVisitorEmail(found.email || "");
|
||||
setVisitorPhone(found.phoneNumber || "");
|
||||
setNotificationPref(found.notificationPreference);
|
||||
setAccountExists(true);
|
||||
setMatch(data.user);
|
||||
setLookupMessage("Account found — details filled in below.");
|
||||
} else {
|
||||
// No match — still save the retype: carry the query into whichever field it
|
||||
// resembles, and leave everything else blank for a fresh entry.
|
||||
setAccountExists(false);
|
||||
setVisitorName("");
|
||||
setNotificationPref("email");
|
||||
if (isEmailLike) {
|
||||
setVisitorEmail(q);
|
||||
setVisitorPhone("");
|
||||
} else if (looksLikePhone(q)) {
|
||||
setVisitorPhone(q);
|
||||
setVisitorEmail("");
|
||||
} else {
|
||||
setVisitorEmail("");
|
||||
setVisitorPhone("");
|
||||
}
|
||||
setLookupMessage("No matching account found.");
|
||||
setMatch(null);
|
||||
setLookupMessage(null);
|
||||
}
|
||||
} catch {
|
||||
setLookupMessage("Lookup failed. Please try again.");
|
||||
@@ -498,13 +566,31 @@ export default function SelfServicePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleLookup();
|
||||
}
|
||||
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) {
|
||||
@@ -540,15 +626,18 @@ export default function SelfServicePage() {
|
||||
<form onSubmit={handleChangeEvent} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Your password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<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">
|
||||
@@ -597,15 +686,18 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(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="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<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
|
||||
@@ -674,46 +766,7 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor's details.</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={lookupQuery}
|
||||
onChange={(e) => setLookupQuery(e.target.value)}
|
||||
onKeyDown={handleLookupKeyDown}
|
||||
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Email or phone number"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLookup}
|
||||
disabled={lookupLoading || !lookupQuery.trim()}
|
||||
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
|
||||
>
|
||||
{lookupLoading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{lookupMessage && (
|
||||
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
|
||||
{lookupMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<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>
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
@@ -730,6 +783,7 @@ export default function SelfServicePage() {
|
||||
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"
|
||||
/>
|
||||
@@ -749,11 +803,40 @@ export default function SelfServicePage() {
|
||||
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>
|
||||
@@ -888,14 +971,17 @@ export default function SelfServicePage() {
|
||||
{createAccount && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(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="Min. 6 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<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>
|
||||
@@ -909,7 +995,7 @@ export default function SelfServicePage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formLoading}
|
||||
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"}
|
||||
@@ -919,6 +1005,43 @@ export default function SelfServicePage() {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user