Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
};
|
||||
Reference in New Issue
Block a user