Merge branch 'feat/sponsor-a-spot'
This commit is contained in:
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.20.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot).
|
||||
- It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it.
|
||||
- Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email.
|
||||
- Sponsors can give anonymously, or add a name and a message for the organisers.
|
||||
- An optional goal ("12 of 20 spots sponsored") and public progress bar.
|
||||
- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool.
|
||||
- Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly.
|
||||
- Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown.
|
||||
- Who receives help is never shown to sponsors.
|
||||
- Sponsors get a "Thank you for sponsoring a spot" receipt.
|
||||
- Automations has a new "Thank Your Sponsors" email for events with sponsorships.
|
||||
- Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`).
|
||||
- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining.
|
||||
- New `/api/sponsorships` endpoints.
|
||||
- Requires the `add_sponsorships` database migration.
|
||||
|
||||
## [1.19.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+11
-1
@@ -171,6 +171,16 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
Each payment records **who recorded it** as well as who it's for.
|
||||
- **Refunds** — negative payments against an original payment, netted against
|
||||
the original method.
|
||||
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
|
||||
lets anyone, including guests, pay for one or more spots (at the main ticket price)
|
||||
or any amount through Yoco. They can give anonymously or add a name and a message.
|
||||
The money is a donation tagged to the event and goes into its sponsorship pool, which
|
||||
supervisors assign to registrations with the usual donation flow.
|
||||
- An optional public progress bar ("12 of 20 spots sponsored") with an optional goal.
|
||||
- Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an
|
||||
event's sponsors.
|
||||
- The Donations breakdown report shows each pool: received, assigned and remaining.
|
||||
- Who is helped is never shown.
|
||||
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
@@ -388,7 +398,7 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
|
||||
@@ -24,6 +24,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
- [Sponsor a spot](#sponsor-a-spot)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -281,6 +282,24 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
|
||||
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
|
||||
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
|
||||
|
||||
How the money is handled:
|
||||
- A spot costs the cheapest paid main ticket.
|
||||
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
|
||||
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
|
||||
|
||||
Other changes:
|
||||
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
|
||||
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
|
||||
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
|
||||
|
||||
## Rooms & groups
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "sponsorshipGoalSpots" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorMessage" TEXT,
|
||||
ADD COLUMN "sponsorName" TEXT;
|
||||
|
||||
@@ -146,6 +146,10 @@ model Event {
|
||||
waitlistEnabled Boolean @default(false)
|
||||
allowTicketTransfer Boolean @default(true)
|
||||
collectGender Boolean @default(false)
|
||||
// Sponsor a spot: public donations into a bursary pool for this event.
|
||||
allowSponsorships Boolean @default(false)
|
||||
sponsorshipGoalSpots Int?
|
||||
showSponsorshipProgress Boolean @default(true)
|
||||
allocationSets AllocationSet[]
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
@@ -330,6 +334,11 @@ model Payment {
|
||||
registrationId String?
|
||||
eventId String?
|
||||
isDonation Boolean @default(false)
|
||||
// A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool.
|
||||
isSponsorship Boolean @default(false)
|
||||
sponsorName String?
|
||||
sponsorMessage String?
|
||||
sponsorAnonymous Boolean @default(false)
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
@@ -737,167 +746,6 @@ model EventCashupPersonCountDenomination {
|
||||
@@index([countId])
|
||||
}
|
||||
|
||||
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
||||
//
|
||||
// model Ministry {
|
||||
// id String @id @default(uuid())
|
||||
// slug String @unique
|
||||
// title String
|
||||
// description String?
|
||||
// image String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// leaderId String?
|
||||
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
|
||||
// interests MinistryInterest[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// @@index([leaderId])
|
||||
// }
|
||||
//
|
||||
// model TeamMember {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// title String
|
||||
// bio String?
|
||||
// photo String?
|
||||
// order Int @default(0)
|
||||
// isActive Boolean @default(true)
|
||||
// email String?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// ministries Ministry[]
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ContentBlock {
|
||||
// id String @id @default(uuid())
|
||||
// page String
|
||||
// key String
|
||||
// value String
|
||||
// type String @default("text")
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@unique([page, key])
|
||||
// @@index([page])
|
||||
// }
|
||||
//
|
||||
// model ChurchDocument {
|
||||
// id String @id @default(uuid())
|
||||
// title String
|
||||
// description String?
|
||||
// fileUrl String
|
||||
// category String @default("other")
|
||||
// isPublic Boolean @default(true)
|
||||
// uploadedAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// }
|
||||
//
|
||||
// model FellowshipGroup {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// description String?
|
||||
// leader String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// members ChurchMember[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ChurchMember {
|
||||
// id String @id @default(uuid())
|
||||
// status String @default("pending")
|
||||
// mainName String
|
||||
// mainEmail String
|
||||
// mainPhone String?
|
||||
// mainBirthday DateTime?
|
||||
// mainOccupation String?
|
||||
// mainEmploymentStatus String?
|
||||
// mainDateOfSalvation DateTime?
|
||||
// mainDateOfWaterBaptism DateTime?
|
||||
// mainHolySpiritBaptism Boolean @default(false)
|
||||
// spouseName String?
|
||||
// spouseEmail String?
|
||||
// spousePhone String?
|
||||
// spouseBirthday DateTime?
|
||||
// spouseOccupation String?
|
||||
// spouseEmploymentStatus String?
|
||||
// spouseDateOfSalvation DateTime?
|
||||
// spouseDateOfWaterBaptism DateTime?
|
||||
// spouseHolySpiritBaptism Boolean?
|
||||
// homeAddress String?
|
||||
// inFellowshipGroup Boolean @default(false)
|
||||
// fellowshipGroupId String?
|
||||
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
|
||||
// membershipConfirmed Boolean @default(false)
|
||||
// notes String?
|
||||
// appliedAt DateTime @default(now())
|
||||
// approvedAt DateTime?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// children ChurchChild[]
|
||||
// ministryInterests MinistryInterest[]
|
||||
// donations Donation[]
|
||||
//
|
||||
// @@index([status])
|
||||
// @@index([mainEmail])
|
||||
// @@index([fellowshipGroupId])
|
||||
// }
|
||||
//
|
||||
// model ChurchChild {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// name String
|
||||
// birthday DateTime?
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@index([memberId])
|
||||
// }
|
||||
//
|
||||
// model MinistryInterest {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// ministryId String
|
||||
// forSpouse Boolean @default(false)
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@unique([memberId, ministryId, forSpouse])
|
||||
// @@index([memberId])
|
||||
// @@index([ministryId])
|
||||
// }
|
||||
//
|
||||
// model Donation {
|
||||
// id String @id @default(uuid())
|
||||
// donorName String?
|
||||
// donorEmail String?
|
||||
// donorPhone String?
|
||||
// amount Float
|
||||
// method String
|
||||
// reference String?
|
||||
// note String?
|
||||
// isAnonymous Boolean @default(false)
|
||||
// linkedMemberId String?
|
||||
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([linkedMemberId])
|
||||
// @@index([createdAt])
|
||||
// @@index([method])
|
||||
// }
|
||||
|
||||
// ─── Coupon codes ─────────────────────────────────────────────────────────────
|
||||
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
|
||||
// The discount is locked into a CouponRedemption when applied, like early-bird
|
||||
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
|
||||
|
||||
enum CouponType {
|
||||
percent
|
||||
fixed
|
||||
|
||||
@@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) {
|
||||
html: j.html ? String(j.html) : undefined,
|
||||
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
|
||||
template: 'custom',
|
||||
filter: { status: undefined, attendeeIds: undefined },
|
||||
filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) },
|
||||
};
|
||||
if (j.promoEventId && typeof j.promoEventId === 'string') {
|
||||
payload.promoEventId = j.promoEventId;
|
||||
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -72,6 +72,9 @@ const createEvent = async (req, res) => {
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
allowTicketTransfer: allowTicketTransfer !== false,
|
||||
collectGender: collectGender === true,
|
||||
allowSponsorships: allowSponsorships === true,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== false,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
@@ -538,7 +541,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -563,6 +566,9 @@ const updateEvent = async (req, res) => {
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
|
||||
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
|
||||
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
@@ -1388,6 +1394,24 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
|
||||
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
|
||||
if (filter?.audience === 'sponsors') {
|
||||
recipients.length = 0;
|
||||
registrations.length = 0;
|
||||
const sponsorships = await prisma.payment.findMany({
|
||||
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const seen = new Set();
|
||||
for (const s of sponsorships) {
|
||||
const email = (s.user?.email || '').trim();
|
||||
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
const axios = require('axios');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || error.response?.status || 400).json({ message: safeErrorMessage(error) });
|
||||
|
||||
async function sponsorableEvent(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: String(eventId || '') }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event || !event.isActive) throw sponsorshipError('Event not found', 404);
|
||||
if (!event.allowSponsorships) throw sponsorshipError('This event isn’t taking sponsorships.');
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) throw sponsorshipError('This event has ended and is no longer taking sponsorships.');
|
||||
return event;
|
||||
}
|
||||
|
||||
async function poolFor(event) {
|
||||
const sponsorships = await prisma.payment.findMany({ where: { eventId: event.id, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, select: { id: true, amount: true } });
|
||||
const legs = sponsorships.length
|
||||
? await prisma.payment.findMany({ where: { originalPaymentId: { in: sponsorships.map(s => s.id) }, isDonation: false }, select: { originalPaymentId: true, amount: true } })
|
||||
: [];
|
||||
return poolSummary(sponsorships, legs, spotPrice(event));
|
||||
}
|
||||
|
||||
// @desc Public sponsorship progress for an event (never names who was helped)
|
||||
// @route GET /api/sponsorships/event/:eventId
|
||||
// @access Public
|
||||
const getEventSponsorship = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event || !event.isActive || !event.allowSponsorships) return res.json({ enabled: false });
|
||||
const pool = await poolFor(event);
|
||||
const open = event.cashupStatus !== 'closed' && new Date(event.endDate) >= new Date();
|
||||
res.json({
|
||||
enabled: true,
|
||||
open,
|
||||
spotPrice: spotPrice(event),
|
||||
showProgress: event.showSponsorshipProgress,
|
||||
goalSpots: event.sponsorshipGoalSpots,
|
||||
spotsFunded: event.showSponsorshipProgress ? pool.spotsFunded : null,
|
||||
sponsors: event.showSponsorshipProgress ? pool.sponsors : null,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
|
||||
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
|
||||
// @access Public (guests give a name and email for the receipt)
|
||||
const createSponsorCheckout = async (req, res) => {
|
||||
try {
|
||||
const { eventId, spots, amount, anonymous, sponsorName, message, name, email, phone } = req.body || {};
|
||||
const event = await sponsorableEvent(eventId);
|
||||
const charge = sponsorshipCharge({ spots, amount }, spotPrice(event));
|
||||
|
||||
let payer = req.user || null;
|
||||
if (!payer) {
|
||||
const { hasValidEmail, findOrCreateAccount } = require('../utils/accountMatch');
|
||||
const cleanName = String(name || '').trim().slice(0, 100);
|
||||
if (!cleanName) throw sponsorshipError('Enter your name.');
|
||||
if (!hasValidEmail(email)) throw sponsorshipError('Enter your email address so we can send your receipt.');
|
||||
// An existing account is reused; otherwise a quiet inactive one holds the receipt —
|
||||
// no activation link is sent, since a sponsor doesn't need to log in.
|
||||
payer = (await findOrCreateAccount({ name: cleanName, email: String(email).trim(), phone })).user;
|
||||
}
|
||||
const isAnonymous = anonymous === true;
|
||||
const displayName = isAnonymous ? null : (String(sponsorName || '').trim() || payer.name || '').slice(0, 100) || null;
|
||||
const note = String(message || '').trim().slice(0, 300) || null;
|
||||
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const back = `${base}/events/${encodeURIComponent(event.id)}`;
|
||||
const amountInCents = Math.round(charge.amount * 100);
|
||||
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
metadata: {
|
||||
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
|
||||
sponsorAnonymous: isAnonymous ? 'true' : 'false',
|
||||
...(displayName ? { sponsorName: displayName } : {}),
|
||||
...(note ? { sponsorMessage: note } : {}),
|
||||
...(charge.spots ? { spots: String(charge.spots) } : {}),
|
||||
},
|
||||
successUrl: `${back}?sponsored=1`,
|
||||
cancelUrl: back,
|
||||
failureUrl: `${back}?sponsored=failed`,
|
||||
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
|
||||
};
|
||||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||||
});
|
||||
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
|
||||
} catch (error) {
|
||||
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The sponsorship pool for an event (supervisors)
|
||||
// @route GET /api/sponsorships/event/:eventId/pool
|
||||
// @access Private/Supervisor
|
||||
const getPool = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event) throw sponsorshipError('Event not found', 404);
|
||||
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
|
||||
@@ -325,7 +325,13 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id
|
||||
isDonation: !registration?.id,
|
||||
...(!registration?.id && metadata?.type === 'sponsorship' ? {
|
||||
isSponsorship: true,
|
||||
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
|
||||
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
|
||||
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
|
||||
} : {})
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
||||
@@ -180,6 +180,7 @@ app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/allocations', require('./routes/allocationRoutes'));
|
||||
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -636,6 +637,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
|
||||
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
|
||||
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
|
||||
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
|
||||
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
|
||||
]},
|
||||
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
|
||||
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
|
||||
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/event/:eventId', getEventSponsorship);
|
||||
router.get('/event/:eventId/pool', protect, supervisor, getPool);
|
||||
router.post('/checkout', optionalAuth, createSponsorCheckout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -533,7 +533,7 @@ function buildPaymentReceipt(payment) {
|
||||
}
|
||||
|
||||
// Donation receipt
|
||||
const subject = `Donation received – ${eventTitle}`;
|
||||
const subject = payment.isSponsorship ? `Thank you for sponsoring a spot – ${eventTitle}` : `Donation received – ${eventTitle}`;
|
||||
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
|
||||
@@ -541,7 +541,7 @@ function buildPaymentReceipt(payment) {
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
|
||||
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.${payment.isSponsorship ? ' It goes into the sponsorship pool and helps someone attend who otherwise couldn’t.' : ''}
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Sponsor a spot — public donations into an event's bursary pool.
|
||||
*
|
||||
* A sponsorship is an ordinary event donation (isDonation) also flagged isSponsorship,
|
||||
* so supervisors hand it out with the existing assign/unassign-donation flow. These
|
||||
* helpers are pure: what one spot costs, what a sponsor is charged, and the pool totals.
|
||||
*/
|
||||
|
||||
const { computeDonationRemaining } = require('./donationUtils');
|
||||
|
||||
const MIN_AMOUNT = 15; // Yoco's minimum charge, same as other donations
|
||||
const MAX_SPOTS = 100;
|
||||
const MAX_AMOUNT = 100000;
|
||||
|
||||
function sponsorshipError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** The price of one spot: the cheapest paid main ticket (or the event price), or null if free. */
|
||||
function spotPrice(event) {
|
||||
const prices = (event.eventOptions || [])
|
||||
.filter(o => o.isMainTicket && Number(o.price) > 0)
|
||||
.map(o => Number(o.price));
|
||||
if (prices.length) return Math.min(...prices);
|
||||
return Number(event.price) > 0 ? Number(event.price) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sponsor pays: `spots` whole spots, or a custom `amount`.
|
||||
* @returns {{ amount: number, spots: number|null }}
|
||||
*/
|
||||
function sponsorshipCharge({ spots, amount }, price) {
|
||||
if (spots !== undefined && spots !== null && spots !== '') {
|
||||
const n = Number(spots);
|
||||
if (!Number.isInteger(n) || n < 1 || n > MAX_SPOTS) throw sponsorshipError(`Choose between 1 and ${MAX_SPOTS} spots.`);
|
||||
if (!price) throw sponsorshipError('This event has no ticket price — enter an amount instead.');
|
||||
return { amount: Math.round(n * price * 100) / 100, spots: n };
|
||||
}
|
||||
const a = Math.round(Number(amount) * 100) / 100;
|
||||
if (!Number.isFinite(a) || a <= 0) throw sponsorshipError('Enter an amount, or choose a number of spots.');
|
||||
if (a < MIN_AMOUNT) throw sponsorshipError(`The minimum is R${MIN_AMOUNT}.`);
|
||||
if (a > MAX_AMOUNT) throw sponsorshipError(`For gifts over R${MAX_AMOUNT.toLocaleString('en-ZA')}, please contact us directly.`);
|
||||
return { amount: a, spots: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool totals for an event.
|
||||
* @param {Array} sponsorships - sponsorship donation payments [{ id, amount }]
|
||||
* @param {Array} legs - payments with originalPaymentId set (assignments, refunds)
|
||||
* @param {number|null} price - one spot
|
||||
*/
|
||||
function poolSummary(sponsorships, legs, price) {
|
||||
const legsBy = new Map();
|
||||
for (const l of legs || []) legsBy.set(l.originalPaymentId, [...(legsBy.get(l.originalPaymentId) || []), l]);
|
||||
let received = 0, remaining = 0;
|
||||
for (const s of sponsorships || []) {
|
||||
received += Number(s.amount) || 0;
|
||||
remaining += Math.max(0, computeDonationRemaining(s.amount, legsBy.get(s.id) || []));
|
||||
}
|
||||
const round = (n) => Math.round(n * 100) / 100;
|
||||
return {
|
||||
received: round(received),
|
||||
assigned: round(received - remaining),
|
||||
remaining: round(remaining),
|
||||
sponsors: (sponsorships || []).length,
|
||||
spotsFunded: price ? Math.floor((received + 0.001) / price) : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { MIN_AMOUNT, MAX_SPOTS, sponsorshipError, spotPrice, sponsorshipCharge, poolSummary };
|
||||
@@ -0,0 +1,44 @@
|
||||
const { spotPrice, sponsorshipCharge, poolSummary } = require('../src/utils/sponsorship');
|
||||
|
||||
describe('spotPrice', () => {
|
||||
test('is the cheapest paid main ticket', () => {
|
||||
expect(spotPrice({ price: 0, eventOptions: [
|
||||
{ isMainTicket: true, price: 450 }, { isMainTicket: true, price: 300 }, { isMainTicket: false, price: 50 }, { isMainTicket: true, price: 0 },
|
||||
] })).toBe(300);
|
||||
});
|
||||
|
||||
test('falls back to the event price, or null for free events', () => {
|
||||
expect(spotPrice({ price: 200, eventOptions: [] })).toBe(200);
|
||||
expect(spotPrice({ price: 0, eventOptions: [{ isMainTicket: true, price: 0 }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsorshipCharge', () => {
|
||||
test('whole spots cost the spot price each', () => {
|
||||
expect(sponsorshipCharge({ spots: 3 }, 450)).toEqual({ amount: 1350, spots: 3 });
|
||||
});
|
||||
|
||||
test('a custom amount is taken as given (2 decimals)', () => {
|
||||
expect(sponsorshipCharge({ amount: '120.555' }, 450)).toEqual({ amount: 120.56, spots: null });
|
||||
});
|
||||
|
||||
test('rejects bad input', () => {
|
||||
expect(() => sponsorshipCharge({ spots: 0 }, 450)).toThrow(/between 1/);
|
||||
expect(() => sponsorshipCharge({ spots: 1.5 }, 450)).toThrow();
|
||||
expect(() => sponsorshipCharge({ spots: 1 }, null)).toThrow(/no ticket price/);
|
||||
expect(() => sponsorshipCharge({ amount: 10 }, 450)).toThrow(/minimum/);
|
||||
expect(() => sponsorshipCharge({}, 450)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poolSummary', () => {
|
||||
test('received, assigned and remaining, with spots funded', () => {
|
||||
const sponsorships = [{ id: 'a', amount: 900 }, { id: 'b', amount: 300 }];
|
||||
const legs = [{ originalPaymentId: 'a', amount: 450 }, { originalPaymentId: 'b', amount: -100 }];
|
||||
expect(poolSummary(sponsorships, legs, 450)).toEqual({ received: 1200, assigned: 550, remaining: 650, sponsors: 2, spotsFunded: 2 });
|
||||
});
|
||||
|
||||
test('no spot count for free events', () => {
|
||||
expect(poolSummary([{ id: 'a', amount: 50 }], [], null).spotsFunded).toBeNull();
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -989,6 +989,16 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
const [enablePre, setEnablePre] = React.useState(true);
|
||||
const [enableFinal, setEnableFinal] = React.useState(true);
|
||||
const [enableThanks, setEnableThanks] = React.useState(true);
|
||||
// Sponsors of "sponsor a spot" events get their own thank-you.
|
||||
const [enableSponsors, setEnableSponsors] = React.useState(true);
|
||||
const [sponsorSubject, setSponsorSubject] = React.useState('Thank you for sponsoring a spot at {{event.title}}');
|
||||
const [sponsorBody, setSponsorBody] = React.useState(`Hi {{name}}
|
||||
|
||||
Thank you for sponsoring a spot at {{event.title}}. Because of you, someone was able to be there who otherwise couldn't.
|
||||
|
||||
[add a highlight or two]
|
||||
|
||||
With gratitude`);
|
||||
const [enablePromo, setEnablePromo] = React.useState(false);
|
||||
|
||||
// Subjects/Bodies
|
||||
@@ -1064,6 +1074,11 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
|
||||
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
|
||||
@@ -1160,6 +1175,20 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={thanksBody} onChange={e => setThanksBody(e.target.value)} />
|
||||
</fieldset>
|
||||
|
||||
{currentEvent?.allowSponsorships && (
|
||||
<fieldset className="border rounded p-3">
|
||||
<legend className="text-sm font-medium">Thank Your Sponsors</legend>
|
||||
<label className="inline-flex items-center gap-2 text-sm mb-2">
|
||||
<input type="checkbox" checked={enableSponsors} onChange={e => setEnableSponsors(e.target.checked)} /> Enable
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Goes to everyone who sponsored a spot at this event, at the same time as the thank-you above.</p>
|
||||
<label className="block text-xs text-gray-600 mb-1">Subject</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={sponsorSubject} onChange={e => setSponsorSubject(e.target.value)} />
|
||||
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={sponsorBody} onChange={e => setSponsorBody(e.target.value)} />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<fieldset className="border rounded p-3">
|
||||
<legend className="text-sm font-medium">Next Event Promo</legend>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
|
||||
@@ -774,6 +774,7 @@ interface EventDraft {
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -786,6 +787,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -826,6 +828,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
allowTicketTransfer: ev.allowTicketTransfer !== false,
|
||||
collectGender: !!ev.collectGender,
|
||||
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -1108,6 +1111,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
allowTicketTransfer: draft.allowTicketTransfer,
|
||||
collectGender: draft.collectGender,
|
||||
allowSponsorships: draft.allowSponsorships,
|
||||
sponsorshipGoalSpots: draft.allowSponsorships && draft.sponsorshipGoalSpots ? parseInt(draft.sponsorshipGoalSpots, 10) || null : null,
|
||||
showSponsorshipProgress: draft.showSponsorshipProgress,
|
||||
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
|
||||
waitlistPartialOffers: draft.waitlistPartialOffers,
|
||||
contactName: draft.contactName || undefined,
|
||||
@@ -1403,6 +1409,27 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Sponsor a spot</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowSponsorships} onChange={e => upd({ allowSponsorships: e.target.checked })} />
|
||||
<span><strong>Allow sponsorships</strong> — a "Sponsor a spot" button on the event page lets people pay for spots (or any amount) for those who can't afford to come. The money goes into a sponsorship pool you assign under Payments → Donations</span>
|
||||
</label>
|
||||
{draft.allowSponsorships && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Goal
|
||||
<input type="number" min="1" className="border rounded px-2 py-1 text-sm w-20" value={draft.sponsorshipGoalSpots} onChange={e => upd({ sponsorshipGoalSpots: e.target.value })} placeholder="—" />
|
||||
spots (optional)
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.showSponsorshipProgress} onChange={e => upd({ showSponsorshipProgress: e.target.checked })} />
|
||||
<span>Show progress on the event page ("12 of 20 spots sponsored")</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ticket transfer</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -832,7 +832,7 @@ function PaymentsContent() {
|
||||
return (
|
||||
<li key={p.id} className={`border rounded p-2 ${isRefund ? 'bg-red-50' : ''}`}>
|
||||
<div className="flex justify-between">
|
||||
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">(donation)</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
|
||||
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">({p.isSponsorship ? 'sponsorship' : 'donation'})</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Method: {p.method || 'payment'}</div>
|
||||
@@ -1171,6 +1171,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
const [paymentId, setPaymentId] = useState<string>("");
|
||||
const [amountStr, setAmountStr] = useState<string>("");
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [sponsorshipsOnly, setSponsorshipsOnly] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setRegistrationId(""); setPaymentId(""); }, [userId]);
|
||||
@@ -1219,10 +1220,11 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
return payments.filter((p: any) => {
|
||||
if (!p.isDonation || p.registrationId) return false;
|
||||
if (String(p.eventId) !== String(eventId)) return false;
|
||||
if (sponsorshipsOnly && !p.isSponsorship) return false;
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
return remaining > 0.000001;
|
||||
});
|
||||
}, [payments, selectedRegistration, legsById]);
|
||||
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
|
||||
|
||||
const selectedDonation = useMemo(
|
||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||
@@ -1306,12 +1308,19 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
<option value="">Select donation…</option>
|
||||
{donationsForEvent.map((p: any) => {
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
|
||||
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
|
||||
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||
})}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={sponsorshipsOnly} onChange={e => { setSponsorshipsOnly(e.target.checked); setPaymentId(""); }} /> Only the sponsorship pool
|
||||
</label>
|
||||
{registrationId && donationsForEvent.length === 0 && (
|
||||
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
|
||||
<div className="text-xs text-gray-500">No {sponsorshipsOnly ? "sponsorships" : "donations"} with a remaining balance for this event.</div>
|
||||
)}
|
||||
{selectedDonation?.isSponsorship && selectedDonation.sponsorMessage && (
|
||||
<div className="text-xs text-rose-800 bg-rose-50 rounded p-2">Sponsor's message: “{selectedDonation.sponsorMessage}”</div>
|
||||
)}
|
||||
|
||||
{selectedDonation && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
|
||||
import { SponsorSpot } from "@/components/events/SponsorSpot";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -42,6 +43,7 @@ type Event = {
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
waitlistEnabled?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
@@ -222,6 +224,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,6 +238,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="mt-4">
|
||||
<RegisterCta event={event} />
|
||||
</div>
|
||||
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const rand = (n: number) => `R${n.toLocaleString("en-ZA", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`;
|
||||
|
||||
/**
|
||||
* "Sponsor a spot": progress towards the event's sponsorship goal, and a form to fund
|
||||
* one or more spots (or any amount) for people who can't afford to come. Payment is
|
||||
* through Yoco; the money lands in the event's sponsorship pool.
|
||||
*/
|
||||
export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTitle: string }) {
|
||||
const { token, user } = useAuth();
|
||||
const [data, setData] = useState<Sponsorship | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"spots" | "amount">("spots");
|
||||
const [spots, setSpots] = useState(1);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [anonymous, setAnonymous] = useState(false);
|
||||
const [sponsorName, setSponsorName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [guest, setGuest] = useState({ name: "", email: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returned, setReturned] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
|
||||
try {
|
||||
const s = new URLSearchParams(window.location.search).get("sponsored");
|
||||
if (s) setReturned(s);
|
||||
} catch { /* no window */ }
|
||||
}, [eventId]);
|
||||
|
||||
if (!data?.enabled) return null;
|
||||
const price = data.spotPrice || null;
|
||||
const loggedIn = !!(token && user);
|
||||
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
|
||||
const goal = data.goalSpots || 0;
|
||||
const funded = data.spotsFunded ?? 0;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/sponsorships/checkout", {
|
||||
method: "POST",
|
||||
...(loggedIn ? { authToken: token! } : {}),
|
||||
body: {
|
||||
eventId,
|
||||
...(mode === "spots" && price ? { spots } : { amount: Number(amount) }),
|
||||
anonymous,
|
||||
...(!anonymous && sponsorName.trim() ? { sponsorName: sponsorName.trim() } : {}),
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(!loggedIn ? { name: guest.name, email: guest.email } : {}),
|
||||
},
|
||||
});
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't start the payment");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-4 space-y-2">
|
||||
{returned === "1" && <div className="text-sm rounded-lg bg-green-50 text-green-800 p-2">Thank you for sponsoring! Your receipt is on its way.</div>}
|
||||
{returned === "failed" && <div className="text-sm rounded-lg bg-red-50 text-red-800 p-2">The payment didn't go through. You can try again.</div>}
|
||||
{data.showProgress && price && (goal > 0 || funded > 0) && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{goal > 0 ? `${Math.min(funded, goal)} of ${goal} spots sponsored` : `${funded} spot${funded === 1 ? "" : "s"} sponsored`}</span>
|
||||
{data.sponsors ? <span>{data.sponsors} sponsor{data.sponsors === 1 ? "" : "s"}</span> : null}
|
||||
</div>
|
||||
{goal > 0 && (
|
||||
<div className="h-2 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={goal} aria-valuenow={Math.min(funded, goal)}>
|
||||
<div className="h-full bg-rose-500" style={{ width: `${Math.min(100, (funded / goal) * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data.open && (
|
||||
<button onClick={() => setOpen(true)} className="w-full inline-flex items-center justify-center gap-2 border border-rose-300 text-rose-700 px-4 py-2 rounded-lg hover:bg-rose-50 font-medium text-sm">
|
||||
<HeartHandshake className="w-4 h-4" /> Sponsor a spot
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" /> Sponsor a spot</h3>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Help someone come to {eventTitle} who couldn't otherwise afford it. Your gift goes into a sponsorship pool that the organisers use to cover places.</p>
|
||||
|
||||
{price ? (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<button type="button" onClick={() => setMode("spots")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
|
||||
<button type="button" onClick={() => setMode("amount")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mode === "spots" && price ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<input type="number" min={1} max={100} className={`${inputCls} w-24`} value={spots} onChange={e => setSpots(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)))} aria-label="Number of spots" />
|
||||
<span>spot{spots === 1 ? "" : "s"} × {rand(price)} = <strong>{rand(spots * price)}</strong></span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Amount (R)</label>
|
||||
<input type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum R15.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loggedIn && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input className={inputCls} placeholder="Your name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
|
||||
<input type="email" className={inputCls} placeholder="Email (for your receipt)" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} /> Give anonymously
|
||||
</label>
|
||||
{!anonymous && (
|
||||
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
|
||||
)}
|
||||
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
|
||||
<p className="text-xs text-gray-500">You'll get a receipt by email. We never share who is helped by sponsorships.</p>
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={busy || !(total >= 15)} className="w-full px-4 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Opening payment…" : `Pay ${total > 0 ? rand(total) : ""} securely`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -970,7 +970,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
const unused = Math.max(total - used, 0);
|
||||
grandTotal += total; grandUsed += used;
|
||||
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length]);
|
||||
const pool = dons.filter((p: any) => p.isSponsorship);
|
||||
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length, Number(poolIn.toFixed(2)), Number(poolUsed.toFixed(2)), Number(Math.max(poolIn - poolUsed, 0).toFixed(2))]);
|
||||
});
|
||||
const grandUnused = Math.max(grandTotal - grandUsed, 0);
|
||||
return {
|
||||
@@ -981,7 +984,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
{ label: 'Unused', value: `R ${grandUnused.toFixed(2)}`, tone: 'amber' },
|
||||
],
|
||||
chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: grandUsed, displayValue: `R ${grandUsed.toFixed(2)}` }, { label: "Unused", value: grandUnused, displayValue: `R ${grandUnused.toFixed(2)}` }] },
|
||||
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
|
||||
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count", "Sponsorship pool (R)", "Pool assigned (R)", "Pool remaining (R)"], rows }
|
||||
};
|
||||
}
|
||||
if (report === "consentStatus") {
|
||||
@@ -1775,7 +1778,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
const unused = Math.max(total - used, 0);
|
||||
grandTotal += total; grandUsed += used; grandCount += dons.length;
|
||||
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length };
|
||||
const pool = dons.filter((p: any) => p.isSponsorship);
|
||||
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
|
||||
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length, poolIn, poolUsed, poolLeft: Math.max(poolIn - poolUsed, 0) };
|
||||
});
|
||||
const grandUnused = Math.max(grandTotal - grandUsed, 0);
|
||||
return (
|
||||
@@ -1799,6 +1805,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<th className="py-2 px-3 text-right">Unused (R)</th>
|
||||
<th className="py-2 px-3 text-right">Count</th>
|
||||
<th className="py-2 px-3 text-right">Avg (R)</th>
|
||||
<th className="py-2 px-3 text-right">Sponsorship pool (R)</th>
|
||||
<th className="py-2 px-3 text-right">Pool assigned (R)</th>
|
||||
<th className="py-2 px-3 text-right">Pool remaining (R)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1810,6 +1819,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<td className="py-2 px-3 text-right">R {row.unused.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">{row.count}</td>
|
||||
<td className="py-2 px-3 text-right">{row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolIn.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolUsed.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {row.poolLeft.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
|
||||
@@ -1819,6 +1831,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
<td className="py-2 px-3 text-right">R {grandUnused.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">{grandCount}</td>
|
||||
<td className="py-2 px-3"></td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolIn, 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolUsed, 0).toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolLeft, 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar, Users } from "lucide-react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar, Users, HeartHandshake } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -25,6 +25,9 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked. Some events have a waitlist: tap "Join waitlist" and you'll be emailed or WhatsApped a link to claim a place if one opens up (it's held for you for a limited time).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartHandshake} title="Sponsor a spot" tone="rose">
|
||||
Some events let you pay for a place for someone who can't afford it. Tap "Sponsor a spot", choose one or more spots (or any amount), and pay securely with Yoco. You can give anonymously or add a name and a message for the organisers. You'll get a receipt, and who is helped is never shared.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
</GuideItem>
|
||||
|
||||
@@ -22,7 +22,7 @@ export const supervisorPaymentsHelpContent: HelpContent = {
|
||||
Refund a specific payment or a registration — pick the user, target, amount and method, and a reason.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Donations" tone="rose">
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration.
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
Match unreconciled Yoco card transactions to a registration, log them as a donation, or ignore them.
|
||||
|
||||
@@ -30,6 +30,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
### For staff on the day
|
||||
|
||||
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
|
||||
- **Sponsor a spot** — let your congregation fund places for people who can't afford to come, with an optional "12 of 20 spots sponsored" progress bar. You decide who receives the help; sponsors never see who.
|
||||
- **Rooms & groups for camps** — drag campers into cabins, small groups or buses (or auto-fill by capacity and gender), print lists for leaders, and show the cabin on each ticket.
|
||||
- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working.
|
||||
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.19.0",
|
||||
"version": "1.20.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user