Compare commits

..
Author SHA1 Message Date
joshua 21176e1e0b Populate email/phone from an unmatched kiosk search query
When the account lookup finds no match, carry the typed query into whichever
of Email/Phone it resembles (email-shaped or phone-shaped) instead of
discarding it, so the operator doesn't have to retype it. Leaves both blank
if it matches neither shape, and resets Name/notification preference for a
fresh entry.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 Replace as-you-type account check with an explicit search field
The kiosk previously ran a debounced background lookup on every keystroke
in the visitor Email/Phone fields, silently autofilling matches. Replaced
with a dedicated "Look up existing account" field that only searches when
the operator presses Enter or clicks Search, and matches exactly against
either email or phone (never both from one query, to avoid digits in an
email being misread as an unrelated phone number). Also simplified the
"account already exists" banner wording.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 Fix self-service kiosk: hide closed events and autofill known visitors
- GET /api/events/all gains an opt-in excludeClosed=true param, used only by
  the kiosk, so closed events no longer show as selectable there while other
  admin/supervisor screens that still need to see closed events are unaffected.
- GET /api/users/check-exists now also returns the matched account's name,
  email, phone, and notification preference (safe fields only). The kiosk's
  existing debounced lookup uses this to autofill whichever fields are still
  blank when a visitor enters an email or phone that matches an existing
  account, without overwriting anything already typed.
2026-07-28 14:21:47 +02:00
joshuaandClaude Sonnet 5 1815f78c85 Bump version to 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:06:08 +02:00
joshuaandClaude Sonnet 5 29036d0612 Merge branch 'feature/door-checkin'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:04:51 +02:00
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
11 changed files with 404 additions and 157 deletions
+19 -1
View File
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Fixed
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
## [1.3.0] - 2026-07-27
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
@@ -57,7 +74,8 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...main
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
try {
const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {};
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
+10 -1
View File
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage,
+14 -3
View File
@@ -482,13 +482,24 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses },
select: { email: true, phoneNumber: true },
select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
});
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({
exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
hasPhone: !!existingUser?.phoneNumber,
hasEmail,
hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
user: existingUser ? {
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+66
View File
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
});
}
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! </p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
}
}
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
@@ -981,4 +1045,6 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
};
+21
View File
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
].join('\n');
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
@@ -255,4 +275,5 @@ module.exports = {
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration);
setMode("payment"); // 🚀 Jump automatically
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
};
const handleRegistrationSelected = (registration: any) => {
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
if (!activeRegistration) return;
const id = setTimeout(() => {
setMode("payment");
// Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0);
return () => clearTimeout(id);
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
const reg = registration || activeRegistration;
try {
const res = await apiFetch<any>("/api/tickets/generate", {
method: "POST",
authToken: token,
body: {
registrationId: reg.id
}
});
const tickets = res?.tickets || [];
if (tickets.length) {
printTickets(tickets);
}
setInfo("Payment recorded & tickets printed");
setActiveRegistration(null);
setMode("registration");
} catch (e: any) {
setError(e?.message || "Tickets failed to generate");
}
// Tickets are generated and emailed/WhatsApped automatically server-side
// once a registration reaches "paid" (see paymentController) — no need to
// fetch/print them here, just move straight to checking the attendee in.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
};
const handleDonation = (user?: any) => {
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
setShowDonationModal(true);
};
const buildTicketHtmlCard = (t: any) => {
const eventTitle =
t.event?.title ||
t.registrationOption?.registration?.event?.title ||
"Event";
const ticketType = ticketLabel(t);
const qty = t.quantity || 1;
const holder =
t.user?.name ||
t.registrationOption?.registration?.user?.name ||
"";
const qrValue = t.qrCode || t.id;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
return `<div class="ticket">
<div class="top">
<div class="evt">${eventTitle}</div>
<div class="type">${ticketType}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${t.id}</div>
</div>`;
};
const openPrintWindow = (cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 194mm;
height: 281mm;
gap: 4mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket {
border: 1.5px solid #222;
border-radius: 3mm;
padding: 5mm;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.top { flex: 0 0 auto; }
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
.qrwrap img { width: 48mm; height: 48mm; display: block; }
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
</style></head><body>${cardsHtml}<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script></body></html>`);
w.document.close();
};
const printTickets = (tickets: any[]) => {
if (!tickets?.length) return;
// 4 per A4 page (2 columns × 2 rows)
const PAGE_SIZE = 4;
const pages: string[] = [];
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
const chunk = tickets.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
: "bg-white hover:bg-gray-50"
}`}
>
{m.toUpperCase()}
{MODE_LABELS[m]}
</button>
))}
</div>
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
/>
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} />
)}
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? `${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null;
+102 -30
View File
@@ -85,6 +85,13 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
}
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
function looksLikePhone(s: string): boolean {
const digits = s.replace(/\D/g, "");
return digits.length >= 9 && digits.length <= 11;
}
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup");
@@ -113,8 +120,12 @@ export default function SelfServicePage() {
const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false);
const [checkingAccount, setCheckingAccount] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ──────────────────
const [lookupQuery, setLookupQuery] = useState("");
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@@ -180,7 +191,7 @@ export default function SelfServicePage() {
setEventsLoading(true);
try {
const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false",
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token }
);
setEvents(data);
@@ -416,6 +427,8 @@ export default function SelfServicePage() {
setCreateAccount(false);
setVisitorPassword("");
setAccountExists(false);
setLookupQuery("");
setLookupMessage(null);
setFormError(null);
setCurrentRegistrationId(null);
setCurrentUserId(null);
@@ -431,33 +444,66 @@ export default function SelfServicePage() {
};
}, []);
// ─── Check whether an account already exists for the entered email/phone ──
useEffect(() => {
const email = visitorEmail.trim();
const phone = visitorPhone.trim();
if (!supervisorToken || (!email && !phone)) {
setAccountExists(false);
return;
}
const handle = setTimeout(async () => {
setCheckingAccount(true);
try {
const params = new URLSearchParams();
if (email) params.set("email", email);
if (phone) params.set("phone", phone);
const data = await apiFetch<{ exists: boolean }>(
`/api/users/check-exists?${params.toString()}`,
{ authToken: supervisorToken }
);
setAccountExists(!!data?.exists);
} catch {
// ─── Explicit account lookup — only runs when the operator submits the search
// (Enter or the Search button), never on keystroke. Matches exactly against a
// single email or phone value and only ever returns that one matched account
// (or nothing) — never a broader/fuzzy match. ──
async function handleLookup() {
const q = lookupQuery.trim();
if (!q || !supervisorToken) return;
setLookupLoading(true);
setLookupMessage(null);
try {
// Classify the query as email- or phone-shaped and send it as only that param —
// sending the same raw string as both could let digits embedded in an email
// (e.g. a numeric local-part) get misread as an unrelated phone number.
const isEmailLike = q.includes("@");
const params = new URLSearchParams();
if (isEmailLike) params.set("email", q);
else params.set("phone", q);
const data = await apiFetch<{
exists: boolean;
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
if (data?.exists && data.user) {
const found = data.user;
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setLookupMessage("Account found — details filled in below.");
} else {
// No match — still save the retype: carry the query into whichever field it
// resembles, and leave everything else blank for a fresh entry.
setAccountExists(false);
} finally {
setCheckingAccount(false);
setVisitorName("");
setNotificationPref("email");
if (isEmailLike) {
setVisitorEmail(q);
setVisitorPhone("");
} else if (looksLikePhone(q)) {
setVisitorPhone(q);
setVisitorEmail("");
} else {
setVisitorEmail("");
setVisitorPhone("");
}
setLookupMessage("No matching account found.");
}
}, 400);
return () => clearTimeout(handle);
}, [visitorEmail, visitorPhone, supervisorToken]);
} catch {
setLookupMessage("Lookup failed. Please try again.");
} finally {
setLookupLoading(false);
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
}
// Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => {
@@ -628,6 +674,34 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor&apos;s details.</p>
<div className="flex gap-2">
<input
type="text"
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
onKeyDown={handleLookupKeyDown}
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Email or phone number"
/>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !lookupQuery.trim()}
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
>
{lookupLoading ? "Searching…" : "Search"}
</button>
</div>
{lookupMessage && (
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
</div>
<form onSubmit={handleRegister} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
@@ -808,9 +882,7 @@ export default function SelfServicePage() {
</div>
<div>
<p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500">
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
<p className="text-sm text-gray-500">Save your details for future events</p>
</div>
</label>
{createAccount && (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.2.0",
"version": "1.3.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",