Merge branch 'feat/registration-donation'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:29:17 +02:00
co-authored by Claude Opus 5.5
16 changed files with 232 additions and 46 deletions
+10
View File
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.30.0] - 2026-10-22
### Added
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
### Fixed
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
## [1.29.1] - 2026-10-21
### Changed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.30.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.29.1",
"version": "1.30.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+23 -6
View File
@@ -935,7 +935,9 @@ function checkoutLineItems(registration) {
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
// donationAmount (optional) is added on top as its own line; the webhook splits it off into a
// separate donation payment using the amount carried in the checkout metadata.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0 } = {}) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
@@ -961,7 +963,8 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
const amountInCents = Math.round(remainingAmount * 100);
const donationInCents = Math.round((Number(donationAmount) || 0) * 100);
const amountInCents = Math.round(remainingAmount * 100) + donationInCents;
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const lineItems = checkoutLineItems(registration);
@@ -974,11 +977,17 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
});
}
// Paying full remaining — no "Partial Payment" line needed
if (donationInCents > 0) {
lineItems.push({ displayName: 'Donation', quantity: 1, pricingDetails: { price: donationInCents } });
}
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
metadata: {
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {})
},
successUrl,
cancelUrl,
failureUrl,
@@ -999,7 +1008,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
return {
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: remainingAmount,
amount: amountInCents / 100,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
};
}
@@ -1009,7 +1018,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
// @access Private
const createYocoCheckout = async (req, res) => {
try {
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl } = req.body;
const userId = req.user.id;
// Validate required parameters for either registration payment or donation
@@ -1096,7 +1105,15 @@ const createYocoCheckout = async (req, res) => {
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
// Optional donation added at registration ("Donation amount" on the register form)
let donation = 0;
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
if (donation > 0 && donation < 15) { res.status(400); throw new Error('Minimum donation is R15'); }
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation });
return res.status(200).json(result);
}
+35 -1
View File
@@ -314,11 +314,18 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
// recorded as its own donation payment so it never counts towards the registration.
const grossAmount = amount / 100; // Convert cents to your currency unit
const donationPart = registration?.id
? Math.min(Math.max(Number(metadata?.donationAmount) || 0, 0), grossAmount)
: 0;
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit
amount: Math.round((grossAmount - donationPart) * 100) / 100,
method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId,
@@ -358,6 +365,24 @@ const handlePaymentSucceeded = async (webhookData) => {
}
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: donationPart,
method: method.type,
status: status === 'succeeded' ? 'completed' : status,
userId: resolvedUserId,
recordedById: resolvedUserId,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
originalPaymentId: payment.id,
}
});
}
// If this payment is for a registration, update the registration status
let updatedRegistration = null;
let generatedTickets = [];
@@ -392,6 +417,7 @@ const handlePaymentSucceeded = async (webhookData) => {
// Send emails: payment confirmation first, then tickets (guarantees order)
const _whPaymentId = payment.id;
const _whDonationId = donationPayment?.id;
const _whUserId = registration?.userId;
const _whRegId = registration?.id;
const _whTicketsGenerated = generatedTickets.length > 0;
@@ -402,6 +428,14 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) {
console.error('Failed to send webhook payment emails:', e);
}
if (_whDonationId) {
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(_whDonationId);
} catch (e) {
console.error('Failed to send webhook donation emails:', e);
}
}
if (_whTicketsGenerated && _whUserId && _whRegId) {
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.29.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.29.1",
"version": "1.30.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.29.1",
"version": "1.30.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+2 -1
View File
@@ -5,7 +5,8 @@ import { Footer } from "@/components/layout/Footer";
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 { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { soldOutLines } from "@/lib/waitlist";
import { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
+6 -4
View File
@@ -18,6 +18,8 @@ function FormsContent() {
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
// Donation chosen on the register form — passed through so the success page can add it to the payment.
const donationParam = search.get("donation") ? `&donation=${encodeURIComponent(search.get("donation")!)}` : "";
// Set when a named attendee opens the link sent to them — show only their form.
const onlyAttendeeId = search.get("attendeeId");
@@ -119,7 +121,7 @@ function FormsContent() {
if (payload.length === 0) {
// Nothing left to submit – still go to success page to continue the flow
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
return;
}
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
@@ -129,7 +131,7 @@ function FormsContent() {
});
// On success, redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failed to submit forms');
}
@@ -149,7 +151,7 @@ function FormsContent() {
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failded to go to next step');
}
@@ -163,7 +165,7 @@ function FormsContent() {
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failded to skip');
}
@@ -257,6 +257,77 @@ function OptionCard({
);
}
// Online donations have the same R15 minimum as the donate page and the backend.
const MIN_DONATION = 15;
// Suggested donations: 10/15/20/25% of the ticket total rounded up to the next R10 (at least R20),
// topped up with fixed amounts when the percentages collapse on a small total.
function donationPresets(total: number): number[] {
const out: number[] = [];
for (const pct of [0.10, 0.15, 0.20, 0.25]) {
const v = Math.max(20, Math.ceil((total * pct) / 10) * 10);
if (!out.includes(v)) out.push(v);
}
for (const v of [50, 100, 200]) {
if (out.length >= 3) break;
if (!out.includes(v) && v > out[out.length - 1]) out.push(v);
}
return out;
}
type DonationChoice = { kind: "none" } | { kind: "preset"; index: number } | { kind: "custom" };
function DonationPicker({
presets,
choice,
onChoiceChange,
customAmount,
onCustomAmountChange,
}: {
presets: number[];
choice: DonationChoice;
onChoiceChange: (c: DonationChoice) => void;
customAmount: string;
onCustomAmountChange: (v: string) => void;
}) {
const btn = (active: boolean) =>
`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${active ? "bg-brand-600 border-brand-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`;
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<div className="font-medium text-sm">Donation amount</div>
<p className="text-xs text-gray-500 mt-0.5 mb-3">Would you like to add a donation to support this event? It&apos;s added to your payment.</p>
<div className="flex flex-wrap gap-2">
{presets.map((amt, i) => (
<button key={i} type="button" className={btn(choice.kind === "preset" && choice.index === i)} onClick={() => onChoiceChange({ kind: "preset", index: i })}>
R{amt}
</button>
))}
<button type="button" className={btn(choice.kind === "custom")} onClick={() => onChoiceChange({ kind: "custom" })}>
Custom
</button>
<button type="button" className={btn(choice.kind === "none")} onClick={() => onChoiceChange({ kind: "none" })}>
None
</button>
</div>
{choice.kind === "custom" && (
<div className="mt-3">
<input
type="number"
min={MIN_DONATION}
step="1"
inputMode="decimal"
autoFocus
placeholder={`Amount in Rand (min R${MIN_DONATION})`}
value={customAmount}
onChange={(e) => onCustomAmountChange(e.target.value)}
className="w-full sm:w-60 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
)}
</div>
);
}
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth();
const eventId = baseEvent.id;
@@ -367,6 +438,17 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
// a code, so its total wins.
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
// Optional donation on top of the tickets — paid in the same Yoco checkout and recorded
// as a separate donation payment. Only offered when there's something to pay online.
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
const [customDonation, setCustomDonation] = useState("");
const presets = useMemo(() => donationPresets(total), [total]);
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
const donation = !showDonation ? 0
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
: 0;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
@@ -391,6 +473,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
if (missing) { setError(missing); return; }
}
if (showDonation && donationChoice.kind === "custom" && donation > 0 && donation < MIN_DONATION) {
setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`);
return;
}
const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : "";
// Claim link without logging in: the link identifies the person.
if (waitlistToken && !isLoggedIn) {
@@ -435,8 +522,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
@@ -573,6 +660,18 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
/>
)}
{showDonation && (
<div className="mt-3">
<DonationPicker
presets={presets}
choice={donationChoice}
onChoiceChange={setDonationChoice}
customAmount={customDonation}
onCustomAmountChange={setCustomDonation}
/>
</div>
)}
{(() => {
// Collect all upcoming early-bird tiers across all options
const now = new Date();
@@ -623,7 +722,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{coupon && coupon.discount > 0 && (
<div className="text-sm text-green-700">Code {coupon.code} −R{coupon.discount.toFixed(2)}</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
{donation > 0 && (
<div className="text-sm text-gray-600">Donation +R{donation.toFixed(2)}</div>
)}
<div className="text-lg font-semibold">Total: R{(payable + donation).toFixed(2)}</div>
</div>
{eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
+16 -2
View File
@@ -21,6 +21,13 @@ function RegistrationSuccessContent() {
return isNaN(n) ? 0 : n;
}, [fallbackTotalParam]);
// Optional donation picked on the register form; added to the Yoco checkout as its own line.
const donationParam = search.get("donation");
const donation = React.useMemo(() => {
const n = donationParam ? Number(donationParam) : 0;
return isNaN(n) || n < 15 ? 0 : Math.round(n * 100) / 100;
}, [donationParam]);
const [reg, setReg] = React.useState<Registration | null>(null);
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -81,7 +88,7 @@ function RegistrationSuccessContent() {
setError(null);
setPayLoading(true);
const { createFullPaymentCheckout } = await import('@/lib/api');
const res = await createFullPaymentCheckout(token, registrationId);
const res = await createFullPaymentCheckout(token, registrationId, donation);
if (res.priceUpdated) {
// Early-bird price changed since registration — surface the new total and
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
@@ -123,7 +130,14 @@ function RegistrationSuccessContent() {
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
<>
{donation > 0 && (
<p className="text-gray-700 mb-2">
Thank you for adding a donation of <strong>R{donation.toFixed(2)}</strong>. It&apos;s included when you pay now.
</p>
)}
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
</>
) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4">
@@ -4,8 +4,9 @@ import React, { useState } from "react";
import { ListPlus, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { WaitlistLine } from "@/lib/waitlist";
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
export type { WaitlistLine };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
@@ -125,21 +126,3 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
function keyOf(l?: WaitlistLine) {
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
}
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
const out: WaitlistLine[] = [];
for (const o of options || []) {
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
const variants = o.variants || [];
if (variants.length > 0) {
for (const v of variants) {
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
}
} else if (optOut) {
out.push({ eventOptionId: o.id, label: o.name });
}
}
return out;
}
+2 -1
View File
@@ -203,12 +203,13 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId,
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
+22
View File
@@ -0,0 +1,22 @@
// Pure helpers shared by server and client components. Must not be "use client",
// or server pages calling these get a client reference instead of the function.
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
const out: WaitlistLine[] = [];
for (const o of options || []) {
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
const variants = o.variants || [];
if (variants.length > 0) {
for (const v of variants) {
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
}
} else if (optOut) {
out.push({ eventOptionId: o.id, label: o.name });
}
}
return out;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.29.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.29.1",
"version": "1.30.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.29.1",
"version": "1.30.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",