diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64aef9f..b02e008 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### 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
diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js
index b020501..2298b81 100644
--- a/backend/src/controllers/ticketController.js
+++ b/backend/src/controllers/ticketController.js
@@ -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,
diff --git a/backend/src/utils/notifications.js b/backend/src/utils/notifications.js
index 5239dae..3776a90 100644
--- a/backend/src/utils/notifications.js
+++ b/backend/src/utils/notifications.js
@@ -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 = `
+
You're checked in! ✅
+
See you inside
+
+
Hi ${ticket.user?.name || 'there'},
+
+ ${qtyRedeemed} ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for ${eventTitle}.
+
+
+ ${callout(`${totalRedeemed} of ${ticket.quantity || 1} checked in${
+ fullyCheckedIn ? '' : ` ${remaining} remaining on this ticket`
+ }`, 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,
};
diff --git a/backend/src/utils/waMessages.js b/backend/src/utils/waMessages.js
index cd99fb8..374d03b 100644
--- a/backend/src/utils/waMessages.js
+++ b/backend/src/utils/waMessages.js
@@ -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,
};
\ No newline at end of file
diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx
index 981f3df..76634cf 100644
--- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx
+++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx
@@ -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 = {
+ 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(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("/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 `
-
-
${eventTitle}
-
${ticketType}
- ${holder ? `
${holder}
` : ""}
-
Qty: ${qty}
-
-
-
${t.id}
-
`;
- };
-
- const openPrintWindow = (cardsHtml: string) => {
- const w = window.open("", "_blank");
- if (!w) return;
- w.document.write(`${cardsHtml}`);
- 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(`