Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+258
View File
@@ -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,
};