@tribe-nest/forge 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAuthActions.ts +1 -1
  4. package/src/data/queries/useCheckouts.ts +5 -0
  5. package/src/data/queries/useEventWaitlist.ts +123 -5
  6. package/src/data/queries/useEvents.ts +16 -0
  7. package/src/data/queries/useMyBookings.ts +25 -0
  8. package/src/data/queries/usePaymentFlow.ts +84 -18
  9. package/src/data/queries/useShipping.ts +5 -0
  10. package/src/data/queries/useSubscriptions.ts +50 -4
  11. package/src/index.ts +5 -0
  12. package/src/server/platform.ts +1 -1
  13. package/src/server/platformEvents.generated.ts +33 -33
  14. package/src/server/pwa.ts +2 -2
  15. package/src/types/models.ts +52 -5
  16. package/src/types/paystack-inline.d.ts +45 -0
  17. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  18. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  19. package/src/ui/format/attendees.ts +2 -2
  20. package/src/ui/format/ticketAvailability.ts +226 -0
  21. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  22. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  23. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  24. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  25. package/src/ui/headless/donation/Donation.tsx +10 -0
  26. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  27. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  28. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  29. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  30. package/src/ui/headless/offer/Offer.tsx +19 -1
  31. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  32. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  33. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  34. package/src/ui/index.ts +22 -0
  35. package/src/ui/shell/diagnosticsGating.ts +3 -3
  36. package/src/ui/styled/AccountDashboard.tsx +73 -4
  37. package/src/ui/styled/AudioPlayer.tsx +1 -1
  38. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  39. package/src/ui/styled/Checkout.tsx +25 -32
  40. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  41. package/src/ui/styled/CoachingBooking.tsx +11 -2
  42. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  43. package/src/ui/styled/CohortPage.tsx +1 -1
  44. package/src/ui/styled/ContactForm.tsx +1 -1
  45. package/src/ui/styled/CourseCheckout.tsx +10 -1
  46. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  47. package/src/ui/styled/DiscountCode.tsx +1 -1
  48. package/src/ui/styled/EmailListForm.tsx +1 -1
  49. package/src/ui/styled/EventConfirmation.tsx +1 -1
  50. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  51. package/src/ui/styled/EventTickets.tsx +135 -21
  52. package/src/ui/styled/EventWaitlist.tsx +23 -3
  53. package/src/ui/styled/HoldNotice.tsx +2 -2
  54. package/src/ui/styled/InvoicePayment.tsx +14 -5
  55. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  56. package/src/ui/styled/OfferButton.tsx +1 -1
  57. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  58. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  59. package/src/ui/styled/PresaleCode.tsx +1 -1
  60. package/src/ui/styled/ReviewForm.tsx +2 -2
  61. package/src/ui/styled/TicketTransfer.tsx +3 -3
  62. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  63. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  64. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  65. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  66. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  67. package/src/utils/membershipAccess.ts +3 -3
  68. package/src/utils/paystackCheckout.ts +277 -0
  69. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -1,4 +1,4 @@
1
- // GENERATED FILE DO NOT EDIT.
1
+ // GENERATED FILE. DO NOT EDIT.
2
2
  // Produced from the platform event catalog by:
3
3
  // cd apps/backend && npm run generate:forge-events
4
4
  //
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  * Every platform event an app can subscribe to, and the exact body it carries.
12
12
  *
13
- * Delivery is at-least-once dedupe on `eventId`. The same event WILL arrive
13
+ * Delivery is at-least-once, so dedupe on `eventId`. The same event WILL arrive
14
14
  * again after a retry, and a handler that acts twice will double-send.
15
15
  *
16
16
  * A body may additionally carry `partial: true`. That means the platform could
@@ -20,7 +20,7 @@
20
20
  */
21
21
  export type PlatformEventMap = {
22
22
  /**
23
- * Affiliate applied Someone applied to the affiliate program.
23
+ * Affiliate applied: Someone applied to the affiliate program.
24
24
  *
25
25
  * Requires the `affiliates.read` grant.
26
26
  */
@@ -28,7 +28,7 @@ export type PlatformEventMap = {
28
28
  affiliateId: string;
29
29
  };
30
30
  /**
31
- * Checkout abandoned Someone started a checkout and did not finish it.
31
+ * Checkout abandoned: Someone started a checkout and did not finish it.
32
32
  *
33
33
  * Requires the `products.read` grant.
34
34
  */
@@ -42,7 +42,7 @@ export type PlatformEventMap = {
42
42
  currency?: string | null;
43
43
  };
44
44
  /**
45
- * Checkout recovered An abandoned checkout was completed after all.
45
+ * Checkout recovered: An abandoned checkout was completed after all.
46
46
  *
47
47
  * Requires the `products.read` grant.
48
48
  */
@@ -56,7 +56,7 @@ export type PlatformEventMap = {
56
56
  currency: string | null;
57
57
  };
58
58
  /**
59
- * Collection entry submitted A visitor submitted an entry to a collection.
59
+ * Collection entry submitted: A visitor submitted an entry to a collection.
60
60
  *
61
61
  * Requires the `collections.read` grant.
62
62
  */
@@ -67,7 +67,7 @@ export type PlatformEventMap = {
67
67
  status?: string | null;
68
68
  };
69
69
  /**
70
- * Community post created A member posted in a community space.
70
+ * Community post created: A member posted in a community space.
71
71
  *
72
72
  * Requires the `community.new_post` grant.
73
73
  */
@@ -77,7 +77,7 @@ export type PlatformEventMap = {
77
77
  authorAccountId?: string | null;
78
78
  };
79
79
  /**
80
- * Community reply created A member replied to a community post.
80
+ * Community reply created: A member replied to a community post.
81
81
  *
82
82
  * Requires the `community.new_comment` grant.
83
83
  */
@@ -89,7 +89,7 @@ export type PlatformEventMap = {
89
89
  parentReplyId?: string | null;
90
90
  };
91
91
  /**
92
- * Contact form message Someone sent a message through a website contact form.
92
+ * Contact form message: Someone sent a message through a website contact form.
93
93
  *
94
94
  * Requires the `contacts.read` grant.
95
95
  */
@@ -99,7 +99,7 @@ export type PlatformEventMap = {
99
99
  message?: string | null;
100
100
  };
101
101
  /**
102
- * Form submitted A form was submitted. Carries the submission id read the answers through the platform client.
102
+ * Form submitted: A form was submitted. Carries the submission id. Read the answers through the platform client.
103
103
  *
104
104
  * Requires the `forms.read` grant.
105
105
  */
@@ -110,7 +110,7 @@ export type PlatformEventMap = {
110
110
  contactId: string | null;
111
111
  };
112
112
  /**
113
- * Quiz completed A contact completed a graded form, with their score and outcome.
113
+ * Quiz completed: A contact completed a graded form, with their score and outcome.
114
114
  *
115
115
  * Requires the `contacts.read` grant.
116
116
  */
@@ -124,7 +124,7 @@ export type PlatformEventMap = {
124
124
  outcomeKey?: string | null;
125
125
  };
126
126
  /**
127
- * Tag added to contact A tag was applied to a contact.
127
+ * Tag added to contact: A tag was applied to a contact.
128
128
  *
129
129
  * Requires the `contacts.read` grant.
130
130
  */
@@ -133,7 +133,7 @@ export type PlatformEventMap = {
133
133
  tagId: string;
134
134
  };
135
135
  /**
136
- * Course completed A learner finished a course.
136
+ * Course completed: A learner finished a course.
137
137
  *
138
138
  * Requires the `courses.read` grant.
139
139
  */
@@ -145,7 +145,7 @@ export type PlatformEventMap = {
145
145
  completedAt: string | null;
146
146
  };
147
147
  /**
148
- * Lesson progressed A learner completed a lesson.
148
+ * Lesson progressed: A learner completed a lesson.
149
149
  *
150
150
  * Requires the `courses.read` grant.
151
151
  */
@@ -157,7 +157,7 @@ export type PlatformEventMap = {
157
157
  cohortId?: string | null;
158
158
  };
159
159
  /**
160
- * Payment disputed A customer disputed a payment (chargeback).
160
+ * Payment disputed: A customer disputed a payment (chargeback).
161
161
  *
162
162
  * Requires the `income.read` grant.
163
163
  */
@@ -167,7 +167,7 @@ export type PlatformEventMap = {
167
167
  reason?: string | null;
168
168
  };
169
169
  /**
170
- * Document signed or decided A proposal or contract was signed, accepted or declined.
170
+ * Document signed or decided: A proposal or contract was signed, accepted or declined.
171
171
  *
172
172
  * Requires the `deals.read` grant.
173
173
  */
@@ -178,7 +178,7 @@ export type PlatformEventMap = {
178
178
  kind?: string | null;
179
179
  };
180
180
  /**
181
- * Email link clicked A recipient clicked a link in a marketing email.
181
+ * Email link clicked: A recipient clicked a link in a marketing email.
182
182
  *
183
183
  * Requires the `emails.read` grant.
184
184
  */
@@ -189,7 +189,7 @@ export type PlatformEventMap = {
189
189
  occurredAt?: string | null;
190
190
  };
191
191
  /**
192
- * Email opened A recipient opened a marketing email.
192
+ * Email opened: A recipient opened a marketing email.
193
193
  *
194
194
  * Requires the `emails.read` grant.
195
195
  */
@@ -199,7 +199,7 @@ export type PlatformEventMap = {
199
199
  occurredAt?: string | null;
200
200
  };
201
201
  /**
202
- * Event tickets bought Someone paid for tickets to an event.
202
+ * Event tickets bought: Someone paid for tickets to an event.
203
203
  *
204
204
  * Requires the `events.read` grant.
205
205
  */
@@ -221,7 +221,7 @@ export type PlatformEventMap = {
221
221
  }>;
222
222
  };
223
223
  /**
224
- * Membership tier archived A membership tier was archived and can no longer be joined.
224
+ * Membership tier archived: A membership tier was archived and can no longer be joined.
225
225
  *
226
226
  * Requires the `membership.tier_archived` grant.
227
227
  */
@@ -231,7 +231,7 @@ export type PlatformEventMap = {
231
231
  archived: boolean;
232
232
  };
233
233
  /**
234
- * Membership tier created A new membership tier was published.
234
+ * Membership tier created: A new membership tier was published.
235
235
  *
236
236
  * Requires the `membership.tier_created` grant.
237
237
  */
@@ -244,7 +244,7 @@ export type PlatformEventMap = {
244
244
  archived: boolean;
245
245
  };
246
246
  /**
247
- * Inbound message An SMS or WhatsApp message arrived from a contact.
247
+ * Inbound message: An SMS or WhatsApp message arrived from a contact.
248
248
  *
249
249
  * Requires the `contacts.read` grant.
250
250
  */
@@ -257,7 +257,7 @@ export type PlatformEventMap = {
257
257
  occurredAt?: string | null;
258
258
  };
259
259
  /**
260
- * Content reported A member reported community content for moderation.
260
+ * Content reported: A member reported community content for moderation.
261
261
  *
262
262
  * Requires the `community.content_reported` grant.
263
263
  */
@@ -267,7 +267,7 @@ export type PlatformEventMap = {
267
267
  entityType?: string | null;
268
268
  };
269
269
  /**
270
- * Order paid A store order was paid for. Carries the order, its line items and a buyer reference.
270
+ * Order paid: A store order was paid for. Carries the order, its line items and a buyer reference.
271
271
  *
272
272
  * Requires the `products.read` grant.
273
273
  */
@@ -291,7 +291,7 @@ export type PlatformEventMap = {
291
291
  }>;
292
292
  };
293
293
  /**
294
- * Payout settled A payout to the creator's bank account settled.
294
+ * Payout settled: A payout to the creator's bank account settled.
295
295
  *
296
296
  * Requires the `income.read` grant.
297
297
  */
@@ -302,7 +302,7 @@ export type PlatformEventMap = {
302
302
  currency: string;
303
303
  };
304
304
  /**
305
- * Member post published A post was published to the member feed, with the tiers that can see it.
305
+ * Member post published: A post was published to the member feed, with the tiers that can see it.
306
306
  *
307
307
  * Requires the `membership.new_post` grant.
308
308
  */
@@ -311,7 +311,7 @@ export type PlatformEventMap = {
311
311
  membershipTiers?: Array<unknown> | null;
312
312
  };
313
313
  /**
314
- * Refund failed A refund could not be completed.
314
+ * Refund failed: A refund could not be completed.
315
315
  *
316
316
  * Requires the `income.read` grant.
317
317
  */
@@ -324,7 +324,7 @@ export type PlatformEventMap = {
324
324
  sourceId?: string | null;
325
325
  };
326
326
  /**
327
- * Refund succeeded A refund completed.
327
+ * Refund succeeded: A refund completed.
328
328
  *
329
329
  * Requires the `income.read` grant.
330
330
  */
@@ -336,7 +336,7 @@ export type PlatformEventMap = {
336
336
  capturedCents?: number | null;
337
337
  };
338
338
  /**
339
- * Review submitted A customer left a review, with its rating and moderation status.
339
+ * Review submitted: A customer left a review, with its rating and moderation status.
340
340
  *
341
341
  * Requires the `reviews.read` grant.
342
342
  */
@@ -350,7 +350,7 @@ export type PlatformEventMap = {
350
350
  isEdit?: boolean | null;
351
351
  };
352
352
  /**
353
- * Review published A review passed moderation and is now public.
353
+ * Review published: A review passed moderation and is now public.
354
354
  *
355
355
  * Requires the `reviews.read` grant.
356
356
  */
@@ -358,7 +358,7 @@ export type PlatformEventMap = {
358
358
  reviewId: string;
359
359
  };
360
360
  /**
361
- * Shipment status changed A shipment for an order moved dispatched, in transit, delivered.
361
+ * Shipment status changed: A shipment for an order moved: dispatched, in transit, delivered.
362
362
  *
363
363
  * Requires the `products.read` grant.
364
364
  */
@@ -371,7 +371,7 @@ export type PlatformEventMap = {
371
371
  carrier?: string | null;
372
372
  };
373
373
  /**
374
- * Website published A website was published.
374
+ * Website published: A website was published.
375
375
  *
376
376
  * Requires the `websites.read` grant.
377
377
  */
@@ -418,5 +418,5 @@ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
418
418
  "website.published": "websites.read",
419
419
  };
420
420
 
421
- /** Every event name, in one array handy for building a UI or validating input. */
421
+ /** Every event name, in one array, handy for building a UI or validating input. */
422
422
  export const PLATFORM_EVENT_NAMES = Object.keys(PLATFORM_EVENT_PERMISSIONS) as PlatformEventName[];
package/src/server/pwa.ts CHANGED
@@ -242,7 +242,7 @@ export function buildPwaHead(opts: BuildPwaHeadOptions): PwaHead {
242
242
  */
243
243
  export function forgeServiceWorkerScript(version: string = FORGE_PWA_VERSION): string {
244
244
  const cacheName = `forge-pwa-v${version}`;
245
- return `// Auto-generated Forge service worker (v${version}). Do not edit served by /sw.js.
245
+ return `// Auto-generated Forge service worker (v${version}). Do not edit. Served by /sw.js.
246
246
  const CACHE = ${JSON.stringify(cacheName)};
247
247
  const OFFLINE_URL = "/offline";
248
248
 
@@ -312,7 +312,7 @@ self.addEventListener("push", (event) => {
312
312
  };
313
313
  event.waitUntil(self.registration.showNotification(title, options));
314
314
  } catch (err) {
315
- // swallow a malformed push must not crash the SW
315
+ // swallow: a malformed push must not crash the SW
316
316
  }
317
317
  });
318
318
 
@@ -57,13 +57,34 @@ export type PublicTaxQuote = {
57
57
  /**
58
58
  * Superset response of the `/public/<domain>/.../start-payment` endpoint family.
59
59
  * Each domain (orders, events, coaching, courses, invoices, payment-links,
60
- * donations) returns the same core paymentSecret + paymentId + a provider
61
- * plus its own optional extras. The provider field is named inconsistently
62
- * across domains (`paymentProvider` vs `paymentProviderName`); usePaymentFlow
63
- * normalizes it to `provider`.
60
+ * donations) returns the same core (paymentId, a provider, and that provider's
61
+ * session) plus its own optional extras. The provider field is named
62
+ * inconsistently across domains (`paymentProvider` vs `paymentProviderName`);
63
+ * usePaymentFlow normalizes it to `provider`.
64
+ *
65
+ * The session field is provider-specific and the two never overlap:
66
+ * Stripe always populates `paymentSecret` and never the Paystack pair; Paystack
67
+ * always populates `accessCode` + `checkoutUrl` and never `paymentSecret`.
64
68
  */
65
69
  export type PaymentStartResponse = {
66
- paymentSecret: string;
70
+ /**
71
+ * STRIPE ONLY: the PaymentIntent client secret fed to Stripe Elements.
72
+ * Optional on the type because Paystack has no equivalent; still ALWAYS
73
+ * populated by the Stripe branch, whose meaning is frozen.
74
+ */
75
+ paymentSecret?: string;
76
+ /**
77
+ * PAYSTACK ONLY: the checkout-session access code from
78
+ * `/transaction/initialize`, handed to the inline modal. Carries the session,
79
+ * so no public key is involved.
80
+ */
81
+ accessCode?: string;
82
+ /**
83
+ * PAYSTACK ONLY: the hosted checkout URL from the SAME initialize response.
84
+ * The RUNTIME fallback used when the modal cannot open, never a version
85
+ * fallback.
86
+ */
87
+ checkoutUrl?: string;
67
88
  paymentId: string;
68
89
  paymentProvider?: PaymentProviderName;
69
90
  paymentProviderName?: PaymentProviderName;
@@ -1248,8 +1269,34 @@ export type ITicket = {
1248
1269
  quantity: number;
1249
1270
  order: number;
1250
1271
  sold: number;
1272
+ /**
1273
+ * Seats promised to somebody else's IN-FLIGHT checkout — not yet sold, but
1274
+ * not on the table either.
1275
+ *
1276
+ * The server sells on `quantity - sold - held` (`inventoryHold.ts`), so a
1277
+ * surface that shows `quantity - sold` tells a fan "3 left" about seats that
1278
+ * are in other people's carts and then refuses them at reserve. Use
1279
+ * `ticketSeatsAvailable` from `@tribe-nest/forge/ui` rather than subtracting
1280
+ * by hand — it is the one place the two stacks agree with the server.
1281
+ *
1282
+ * Optional, and absent means zero: a site pinned to an API build that
1283
+ * predates the hold engine keeps its previous behaviour instead of reading
1284
+ * every tier as sold out.
1285
+ */
1286
+ held?: number;
1251
1287
  maxPerPerson: number;
1252
1288
  expiresAt?: string;
1289
+ /**
1290
+ * Scheduled on-sale. Before this instant the server refuses the tier with
1291
+ * `ticket_not_yet_on_sale` — `evaluateTierSellability` is the authority.
1292
+ *
1293
+ * The tier stays VISIBLE on purpose: "on sale Friday 10am" is information a
1294
+ * fan wants, and hiding it makes a scheduled on-sale indistinguishable from
1295
+ * the tier not existing. So a renderer must announce the time and refuse the
1296
+ * add, exactly as it does for a members-only tier — see `ticketSaleWindow`
1297
+ * and `onSaleBadge` in `@tribe-nest/forge/ui`.
1298
+ */
1299
+ availableFrom?: string | null;
1253
1300
  archivedAt?: string;
1254
1301
  /**
1255
1302
  * This tier is not listed publicly (1.2). If you are looking at one, it is
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Minimal typings for `@paystack/inline-js` v2, which ships no declarations of
3
+ * its own (no `types` field, no bundled `.d.ts`).
4
+ *
5
+ * This file is pulled into every consuming program by an explicit
6
+ * `/// <reference path>` in `utils/paystackCheckout.ts` rather than by living in
7
+ * a `typeRoots` directory. Forge ships SOURCE, so a site (or `apps/client`)
8
+ * type-checks these files inside its own program, and an ambient declaration
9
+ * that is merely present in the package is not part of that program unless
10
+ * something in the import graph reaches it.
11
+ *
12
+ * Only the surface Forge actually calls is described. Widening it is fine;
13
+ * guessing at shapes we never call is not.
14
+ */
15
+ declare module "@paystack/inline-js" {
16
+ /** What Paystack hands back on a completed transaction. */
17
+ export interface PaystackTransaction {
18
+ /** Our own `paymentId` UUID: the reference `startCharge` minted. */
19
+ reference?: string;
20
+ /** Paystack's echo of the same reference on the callback URL. */
21
+ trxref?: string;
22
+ /** Paystack's internal numeric transaction id, as a string. */
23
+ transaction?: string;
24
+ status?: string;
25
+ message?: string;
26
+ }
27
+
28
+ export interface PaystackResumeOptions {
29
+ onSuccess?: (transaction: PaystackTransaction) => void;
30
+ /** The fan closed the modal. NOT a failure. */
31
+ onCancel?: () => void;
32
+ /** Fires once the checkout iframe has actually loaded. */
33
+ onLoad?: (response: unknown) => void;
34
+ onError?: (error: unknown) => void;
35
+ }
36
+
37
+ export default class PaystackPop {
38
+ /**
39
+ * Open the modal against an existing checkout session. The access code
40
+ * carries the session, so no public key is involved.
41
+ */
42
+ resumeTransaction(accessCode: string, options?: PaystackResumeOptions): unknown;
43
+ cancelTransaction(id?: string): void;
44
+ }
45
+ }
@@ -119,9 +119,9 @@ describe("attendee helpers", () => {
119
119
  it("labels each seat with its tier, and numbers them only when there is more than one", () => {
120
120
  expect(slots({ quantities: { vip: 1 } })[0].label).toBe("VIP");
121
121
  expect(slots({ quantities: { ga: 3 } }).map((s) => s.label)).toEqual([
122
- "General Admission Attendee 1 of 3",
123
- "General Admission Attendee 2 of 3",
124
- "General Admission Attendee 3 of 3",
122
+ "General Admission · Attendee 1 of 3",
123
+ "General Admission · Attendee 2 of 3",
124
+ "General Admission · Attendee 3 of 3",
125
125
  ]);
126
126
  });
127
127
 
@@ -172,7 +172,7 @@ describe("attendee helpers", () => {
172
172
  if (result.ok) return;
173
173
  expect(result.reason).toBe("name_required");
174
174
  expect(result.slot.index).toBe(1);
175
- expect(result.message).toContain("General Admission Attendee 2 of 2");
175
+ expect(result.message).toContain("General Admission · Attendee 2 of 2");
176
176
  });
177
177
 
178
178
  it("refuses whitespace-only, which would otherwise write an empty owner_name", () => {