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>
279 lines
9.8 KiB
JavaScript
279 lines
9.8 KiB
JavaScript
/**
|
||
* WhatsApp-formatted message builders.
|
||
*
|
||
* Styling reference: https://faq.whatsapp.com/539178204879377/
|
||
* *bold* _italic_ ~strikethrough~ ```monospace```
|
||
* > blockquote - unordered list 1. numbered list
|
||
* # Heading 1 ## Heading 2 ### Heading 3
|
||
*/
|
||
|
||
function fmtAmount(amt) {
|
||
return `R${Number(amt || 0).toFixed(2)}`;
|
||
}
|
||
|
||
function fmtDateShort(d) {
|
||
try {
|
||
return new Date(d).toLocaleDateString('en-GB', {
|
||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||
});
|
||
} catch { return String(d); }
|
||
}
|
||
|
||
const { getSettingSync } = require('./settingsCache');
|
||
|
||
function getOrg() {
|
||
return {
|
||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||
};
|
||
}
|
||
|
||
// ─── Registration confirmation ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* @param {object} reg - full registration from loadRegistrationFull
|
||
* @param {{ isNew?: boolean, balance?: number, totalDue?: number, totalPaid?: number }} opts
|
||
*/
|
||
function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid } = {}) {
|
||
const org = getOrg();
|
||
const eventTitle = reg.event?.title || 'the event';
|
||
const eventDate = reg.event?.startDate ? fmtDateShort(reg.event.startDate) : '';
|
||
const name = reg.user?.name || 'there';
|
||
|
||
const heading = isNew ? '🎉 *Registration Confirmed!*' : '✏️ *Registration Updated*';
|
||
const intro = isNew
|
||
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
|
||
: `Your registration for *${eventTitle}* has been updated.`;
|
||
|
||
const items = (reg.registrationOptions || [])
|
||
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
|
||
.join('\n');
|
||
|
||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||
const due = totalDue ?? 0;
|
||
const bal = balance ?? Math.max(due - paid, 0);
|
||
|
||
const finLine = bal <= 0
|
||
? `✅ *Fully paid — you're all set!*`
|
||
: `*Balance due:* ${fmtAmount(bal)}\n_Pay at ${org.url} or at the door._`;
|
||
|
||
return [
|
||
heading,
|
||
'',
|
||
`Hi ${name},`,
|
||
'',
|
||
intro,
|
||
'',
|
||
'*Your selections:*',
|
||
items || '—',
|
||
'',
|
||
`*Total:* ${fmtAmount(due)} *Paid:* ${fmtAmount(paid)}`,
|
||
finLine,
|
||
'',
|
||
`_${org.name}_ | ${org.url}`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||
|
||
function buildWAPayment(payment) {
|
||
const org = getOrg();
|
||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
|
||
const name = (payment.registration?.user || payment.user)?.name || 'there';
|
||
const amount = fmtAmount(payment.amount);
|
||
|
||
const reg = payment.registration;
|
||
let balLine = '';
|
||
if (reg) {
|
||
const { computeRegistrationTotalDue } = require('./pricing');
|
||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||
const balance = Math.max(totalDue - totalPaid, 0);
|
||
balLine = balance <= 0
|
||
? `\n✅ *Fully paid!* Your tickets have been sent.`
|
||
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
|
||
}
|
||
|
||
return [
|
||
`✅ *Payment Received*`,
|
||
'',
|
||
`Hi ${name},`,
|
||
'',
|
||
`We've received your payment of *${amount}* for *${eventTitle}*.`,
|
||
balLine,
|
||
'',
|
||
`_${org.name}_ | ${org.url}`,
|
||
].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 }) {
|
||
const org = getOrg();
|
||
return [
|
||
`🔐 *New Login Detected*`,
|
||
'',
|
||
`Hi ${name || 'there'},`,
|
||
'',
|
||
`A new login to your *${org.name}* account was detected.`,
|
||
'',
|
||
`*Time:* ${when}`,
|
||
`*Location:* ${location}`,
|
||
`*Device:* ${userAgent}`,
|
||
'',
|
||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||
'',
|
||
`_If this was you, no action is needed._`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||
|
||
function buildWAWelcome({ name, events }) {
|
||
const org = getOrg();
|
||
const hasEvents = Array.isArray(events) && events.length > 0;
|
||
|
||
const eventsBlock = hasEvents
|
||
? [
|
||
'*Upcoming events:*',
|
||
...events.map(e =>
|
||
`- *${e.title}* — ${new Date(e.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}`
|
||
),
|
||
].join('\n')
|
||
: 'Keep an eye on our website for upcoming events.';
|
||
|
||
return [
|
||
`🎉 *Welcome to ${org.name}!*`,
|
||
'',
|
||
`Hi ${name || 'there'},`,
|
||
'',
|
||
`Your account is set up and ready. Use it to register for events, manage your bookings, and access your tickets.`,
|
||
'',
|
||
eventsBlock,
|
||
'',
|
||
`${org.url}`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Account closed ───────────────────────────────────────────────────────────
|
||
|
||
function buildWAAccountClosed({ name, dataDeleted }) {
|
||
const org = getOrg();
|
||
const detail = dataDeleted
|
||
? 'All personal data associated with your account has been permanently deleted.'
|
||
: `Your account has been deactivated. To also delete your personal data, contact ${org.email}.`;
|
||
|
||
return [
|
||
`🔒 *Account Closed*`,
|
||
'',
|
||
`Hi ${name || 'there'},`,
|
||
'',
|
||
`Your *${org.name}* account has been successfully closed.`,
|
||
'',
|
||
detail,
|
||
'',
|
||
`_${org.name}_ | ${org.email}`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Ticket delivery caption ──────────────────────────────────────────────────
|
||
|
||
function buildWATicketCaption({ name, eventTitle, eventDate }) {
|
||
return [
|
||
`🎟️ *Your tickets for ${eventTitle}*`,
|
||
'',
|
||
`Hi ${name || 'there'}! Your tickets${eventDate ? ` for *${eventDate}*` : ''} are attached.`,
|
||
'Please show this PDF (printed or on your phone) at the event entrance.',
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Refund notification ──────────────────────────────────────────────────────
|
||
|
||
function buildWARefund(payment) {
|
||
const org = getOrg();
|
||
const user = payment.user;
|
||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'the event';
|
||
const amt = fmtAmount(Math.abs(payment.amount || 0));
|
||
const name = user?.name || 'there';
|
||
|
||
return [
|
||
`💸 *Refund Processed*`,
|
||
'',
|
||
`Hi ${name},`,
|
||
'',
|
||
`A refund of *${amt}* for *${eventTitle}* has been processed.`,
|
||
'',
|
||
`Refunds may take a few business days to appear depending on your bank.`,
|
||
'',
|
||
`_${org.name}_ | ${org.email}`,
|
||
].join('\n');
|
||
}
|
||
|
||
// ─── Donation applied to a registration ────────────────────────────────────────
|
||
//
|
||
// Distinct from buildWAPayment: sent to the REGISTRANT when staff apply someone else's
|
||
// donation to their registration — anonymous (no donor name), and never "payment received"
|
||
// wording since they didn't pay anything themselves.
|
||
|
||
function buildWADonationAppliedToRegistrant(payment) {
|
||
const org = getOrg();
|
||
const reg = payment.registration;
|
||
const eventTitle = reg?.event?.title || 'the event';
|
||
const name = reg?.user?.name || 'there';
|
||
const amount = fmtAmount(payment.amount);
|
||
|
||
let balLine = '';
|
||
if (reg) {
|
||
const { computeRegistrationTotalDue } = require('./pricing');
|
||
const totalDue = computeRegistrationTotalDue(reg, payment.createdAt || new Date());
|
||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||
const balance = Math.max(totalDue - totalPaid, 0);
|
||
balLine = balance <= 0
|
||
? `\n✅ *Fully paid!* Your tickets have been sent.`
|
||
: `\n*Remaining balance:* ${fmtAmount(balance)}\n_Pay the remainder at ${org.url} or at the door._`;
|
||
}
|
||
|
||
return [
|
||
`🎁 *Donation Applied*`,
|
||
'',
|
||
`Hi ${name},`,
|
||
'',
|
||
`A donation of *${amount}* was applied to your registration for *${eventTitle}*.`,
|
||
balLine,
|
||
'',
|
||
`_${org.name}_ | ${org.url}`,
|
||
].join('\n');
|
||
}
|
||
|
||
module.exports = {
|
||
buildWARegistration,
|
||
buildWAPayment,
|
||
buildWARefund,
|
||
buildWADonationAppliedToRegistrant,
|
||
buildWALogin,
|
||
buildWAWelcome,
|
||
buildWAAccountClosed,
|
||
buildWATicketCaption,
|
||
buildWACheckIn,
|
||
}; |