@tribe-nest/forge 3.21.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 (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -1,16 +1,29 @@
1
1
  import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
2
- import { AlertCircle, Clock, X } from "lucide-react";
2
+ import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
3
3
  import type { IEvent, ITicket } from "../../types/models";
4
4
  import { useEventCheckout } from "../headless/event/useEventCheckout";
5
5
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
7
  import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
8
8
  import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
9
+ import {
10
+ maxAddableTickets,
11
+ onSaleBadge,
12
+ onSaleNotice,
13
+ ticketSaleWindow,
14
+ ticketSeatLabel,
15
+ ticketSeatNotice,
16
+ ticketSeatState,
17
+ } from "../format/ticketAvailability";
9
18
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
10
19
  import { Loading } from "./Loading";
20
+ import { PaystackPayButton } from "./PaystackPayButton";
11
21
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
12
22
  import { buttonStyle } from "./Button";
23
+ import { MembershipGateNotice } from "./MembershipGateNotice";
13
24
  import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
25
+ import { PresaleCodeField } from "./PresaleCode";
26
+ import { HoldNotice } from "./HoldNotice";
14
27
  import {
15
28
  DialogRoot,
16
29
  DialogTrigger,
@@ -34,6 +47,13 @@ export interface EventTicketsProps {
34
47
  finalisePath?: (slug: string, orderId: string) => string;
35
48
  /** Host-owned navigation on FREE completion (e.g. router navigate). */
36
49
  onComplete?: (info: { slug: string; orderId: string }) => void;
50
+ /**
51
+ * Where a members-only tier sends a signed-OUT visitor. Default `/login`;
52
+ * code sites mount theirs under `/i`. The sign-in returns them here.
53
+ */
54
+ loginPath?: string;
55
+ /** Where a members-only tier sends a signed-in NON-member — the tier listing. */
56
+ membershipPath?: string;
37
57
  }
38
58
 
39
59
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
@@ -51,6 +71,8 @@ export function EventTickets({
51
71
  renderPayment,
52
72
  finalisePath,
53
73
  onComplete,
74
+ loginPath,
75
+ membershipPath,
54
76
  }: EventTicketsProps) {
55
77
  const theme = useForgeTheme();
56
78
  const registered = usePaymentRenderer();
@@ -109,6 +131,8 @@ export function EventTickets({
109
131
  event={c.event}
110
132
  fmt={fmt}
111
133
  renderPayment={renderPay}
134
+ loginPath={loginPath}
135
+ membershipPath={membershipPath}
112
136
  // Selection is safe in the cart now — close so the buyer can go
113
137
  // browse the add-ons.
114
138
  onAddedToCart={() => setOpen(false)}
@@ -128,19 +152,34 @@ function EventTicketsBody({
128
152
  fmt,
129
153
  renderPayment,
130
154
  onAddedToCart,
155
+ loginPath,
156
+ membershipPath,
131
157
  }: {
132
158
  c: Checkout;
133
159
  event: IEvent;
134
160
  fmt: (n: number) => string;
135
161
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
136
162
  onAddedToCart?: () => void;
163
+ loginPath?: string;
164
+ membershipPath?: string;
137
165
  }) {
138
166
  const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
139
167
  return (
140
168
  <div style={{ width: "100%" }}>
141
169
  <Progress current={stepIndex} />
142
- {c.step === "tickets" && <TicketStep c={c} event={event} fmt={fmt} onAddedToCart={onAddedToCart} />}
143
- {c.step === "details" && <DetailsStep c={c} event={event} fmt={fmt} />}
170
+ {c.step === "tickets" && (
171
+ <TicketStep
172
+ c={c}
173
+ event={event}
174
+ fmt={fmt}
175
+ onAddedToCart={onAddedToCart}
176
+ loginPath={loginPath}
177
+ membershipPath={membershipPath}
178
+ />
179
+ )}
180
+ {c.step === "details" && (
181
+ <DetailsStep c={c} event={event} fmt={fmt} loginPath={loginPath} membershipPath={membershipPath} />
182
+ )}
144
183
  {c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
145
184
  </div>
146
185
  );
@@ -193,11 +232,15 @@ function TicketStep({
193
232
  event,
194
233
  fmt,
195
234
  onAddedToCart,
235
+ loginPath,
236
+ membershipPath,
196
237
  }: {
197
238
  c: Checkout;
198
239
  event: IEvent;
199
240
  fmt: (n: number) => string;
200
241
  onAddedToCart?: () => void;
242
+ loginPath?: string;
243
+ membershipPath?: string;
201
244
  }) {
202
245
  const theme = useForgeTheme();
203
246
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
@@ -225,23 +268,65 @@ function TicketStep({
225
268
  };
226
269
 
227
270
  const setQty = (ticket: ITicket, qty: number) => {
228
- const remaining = ticket.quantity - ticket.sold;
229
- const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
230
- c.setTicketQty(ticket.id, Math.max(0, Math.min(qty, maxAllowed)));
271
+ // `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
272
+ // on-sale the same two facts the server sells on. Computed off `now` so
273
+ // the cap opens up on the tick the on-sale time passes, with no reload.
274
+ //
275
+ // 2.6b adds back the seats held for THIS visitor by a waitlist offer.
276
+ // Without it the offeree's own reservation makes the tier read sold out to
277
+ // them and the claim link is unusable.
278
+ c.setTicketQty(
279
+ ticket.id,
280
+ Math.max(0, Math.min(qty, maxAddableTickets(ticket, new Date(now), c.reservedForYou[ticket.id] ?? 0))),
281
+ );
231
282
  };
232
283
 
233
284
  return (
234
285
  <div>
235
286
  <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Select Tickets</h2>
287
+ {/* Renders nothing on an event with no coded tier (1.2). Here as well as
288
+ on the page because this modal is reachable on its own — from an offer,
289
+ and from the broadcast-pass prompt — where there is no page-level box.
290
+ Both read the SAME stored code, so unlocking in one unlocks the other. */}
291
+ <PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
236
292
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
237
293
  {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
238
294
  {event.tickets.map((ticket) => {
239
295
  const selectedQty = c.selectedTickets[ticket.id] ?? 0;
240
- const remaining = ticket.quantity - ticket.sold;
241
- const maxAllowed = Math.min(remaining, ticket.maxPerPerson ?? remaining);
242
- const isSoldOut = remaining <= 0;
296
+ /**
297
+ * `quantity - sold - HELD`, which is what the server sells on. The
298
+ * old `quantity - sold` showed seats sitting in other buyers' live
299
+ * checkouts as available, so a fan in a rush was told "3 left",
300
+ * picked two, filled in the form and was refused at reserve.
301
+ */
302
+ // 2.6b — the offeree's own reservation is not "somebody else's
303
+ // checkout"; it is the seat they were emailed a deadline for.
304
+ const reserved = c.reservedForYou[ticket.id] ?? 0;
305
+ const seatState = ticketSeatState(ticket, reserved);
306
+ const maxAllowed = maxAddableTickets(ticket, new Date(now), reserved);
307
+ const isSoldOut = seatState !== "available";
308
+ const seatLabel = ticketSeatLabel(seatState);
309
+ const seatNotice = ticketSeatNotice(seatState);
310
+ /**
311
+ * Scheduled on-sale. Enforced since 1.2 and drawn on no storefront,
312
+ * so a tier going on sale Friday was clickable on Wednesday and the
313
+ * `ticket_not_yet_on_sale` refusal arrived on the payment screen.
314
+ */
315
+ const saleWindow = ticketSaleWindow(ticket, new Date(now));
316
+ const isScheduled = saleWindow.status === "scheduled";
243
317
  const u = urgency(ticket.expiresAt);
244
318
  const isExpired = !!u?.expired;
319
+ /**
320
+ * A members-only tier the buyer cannot have (S.4).
321
+ *
322
+ * The event-level gate applies to EVERY tier — "no ticket to this show
323
+ * sells to a non-member" — so it is folded in here rather than drawn
324
+ * once at the top: a buyer reading one tier row must be able to tell
325
+ * from that row whether they can buy it.
326
+ */
327
+ const gate =
328
+ event.membershipGate && !event.membershipGate.allowed ? event.membershipGate : ticket.membershipGate;
329
+ const isGated = !!gate && !gate.allowed;
245
330
  const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
246
331
  return (
247
332
  <div
@@ -257,6 +342,59 @@ function TicketStep({
257
342
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
258
343
  <div style={{ flex: 1 }}>
259
344
  <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
345
+ {/* A hidden tier is only ever on screen because a code
346
+ revealed it — so say so. Without the badge, a code that
347
+ unlocked a tier the buyer had not seen listed looks like it
348
+ did nothing. */}
349
+ {ticket.isHidden && (
350
+ <div
351
+ data-testid="presale-tier-badge"
352
+ style={{
353
+ display: "inline-flex",
354
+ alignItems: "center",
355
+ gap: 4,
356
+ padding: "2px 8px",
357
+ borderRadius: 6,
358
+ fontSize: 12,
359
+ fontWeight: 700,
360
+ marginBottom: 6,
361
+ marginRight: 6,
362
+ background: a(theme.colors.primary, 0.1),
363
+ color: theme.colors.primary,
364
+ border: `1px solid ${a(theme.colors.primary, 0.3)}`,
365
+ }}
366
+ >
367
+ <KeyRound size={12} />
368
+ Presale
369
+ </div>
370
+ )}
371
+ {/* A tier that is announced but not yet buyable says WHEN,
372
+ in the same badge idiom as the presale and expiry ones.
373
+ Deliberately not dimmed and deliberately not hidden: the
374
+ whole point of scheduling an on-sale is that fans can see
375
+ it coming. */}
376
+ {isScheduled && (
377
+ <div
378
+ data-testid="ticket-on-sale-badge"
379
+ style={{
380
+ display: "inline-flex",
381
+ alignItems: "center",
382
+ gap: 4,
383
+ padding: "2px 8px",
384
+ borderRadius: 6,
385
+ fontSize: 12,
386
+ fontWeight: 700,
387
+ marginBottom: 6,
388
+ marginRight: 6,
389
+ background: a(theme.colors.primary, 0.1),
390
+ color: theme.colors.primary,
391
+ border: `1px solid ${a(theme.colors.primary, 0.3)}`,
392
+ }}
393
+ >
394
+ <CalendarClock size={12} />
395
+ {onSaleBadge(saleWindow.startsAt, new Date(now))}
396
+ </div>
397
+ )}
260
398
  {u && !u.expired && (
261
399
  <div
262
400
  style={{
@@ -295,17 +433,28 @@ function TicketStep({
295
433
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
296
434
  <button
297
435
  onClick={() => setQty(ticket, selectedQty - 1)}
298
- disabled={selectedQty === 0 || isSoldOut}
436
+ // Never disabled by availability only by there being
437
+ // nothing to remove. A buyer holding two seats on a tier
438
+ // that has just gone unavailable must still be able to put
439
+ // them back; disabling this would trap the cart.
440
+ disabled={selectedQty === 0}
299
441
  style={stepperStyle(theme, false)}
300
442
  >
301
443
 
302
444
  </button>
303
445
  <span style={{ fontSize: 18, fontWeight: 700, minWidth: 32, textAlign: "center" }}>
304
- {isSoldOut ? "SOLD OUT" : selectedQty}
446
+ {seatLabel ?? selectedQty}
305
447
  </span>
306
448
  <button
307
449
  onClick={() => setQty(ticket, selectedQty + 1)}
308
- disabled={selectedQty >= maxAllowed || isSoldOut || isExpired}
450
+ // A gated tier cannot be added, nor one whose on-sale time
451
+ // has not arrived, nor one whose remaining seats are inside
452
+ // somebody else's checkout. Not because the client is the
453
+ // authority — `createOrder` refuses all three regardless —
454
+ // but because letting a buyer build a cart the server will
455
+ // not sell is precisely the "discoverable only by failing"
456
+ // journey this change exists to end.
457
+ disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled}
309
458
  style={stepperStyle(theme, true)}
310
459
  >
311
460
  +
@@ -316,7 +465,7 @@ function TicketStep({
316
465
  before that there is nothing to price, and a row of empty
317
466
  inputs down the tier list reads as a form to fill in rather
318
467
  than a choice to make. */}
319
- {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
468
+ {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
320
469
  <PwywAmountField
321
470
  ticket={ticket}
322
471
  quantity={selectedQty}
@@ -329,6 +478,35 @@ function TicketStep({
329
478
  {ticket.description && (
330
479
  <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
331
480
  )}
481
+ {/* Named on the TIER, before the buyer has typed anything. The
482
+ tier stays in the list on purpose — hiding it kills the upsell
483
+ — so this is what turns "visible but mysteriously unbuyable"
484
+ into an offer with a way in. */}
485
+ <MembershipGateNotice
486
+ gate={gate}
487
+ variant="badge"
488
+ itemLabel={event.membershipGate && !event.membershipGate.allowed ? "This event" : "This ticket"}
489
+ loginPath={loginPath}
490
+ membershipPath={membershipPath}
491
+ />
492
+ {/* The badge says WHEN in two words; this says it in full, with
493
+ the exact time, for a fan deciding whether to set an alarm. */}
494
+ {isScheduled && (
495
+ <div data-testid="ticket-on-sale-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
496
+ <CalendarClock size={16} />
497
+ {onSaleNotice(saleWindow.startsAt)}
498
+ </div>
499
+ )}
500
+ {/* "ON HOLD" in the stepper is two words; this is the sentence
501
+ that stops it reading as a dead end. Seats inside somebody
502
+ else's checkout come back if that checkout lapses, which a
503
+ fan told only "SOLD OUT" would never wait for. */}
504
+ {seatNotice && (
505
+ <div data-testid="ticket-on-hold-notice" style={infoNoticeStyle("#f59e0b", theme)}>
506
+ <Clock size={16} />
507
+ {seatNotice}
508
+ </div>
509
+ )}
332
510
  {isExpired && (
333
511
  <div
334
512
  style={{
@@ -375,7 +553,19 @@ function TicketStep({
375
553
  }
376
554
 
377
555
  // ── Step 2: buyer details + questionnaire ─────────────────────────────────────
378
- function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: number) => string }) {
556
+ function DetailsStep({
557
+ c,
558
+ event,
559
+ fmt,
560
+ loginPath,
561
+ membershipPath,
562
+ }: {
563
+ c: Checkout;
564
+ event: IEvent;
565
+ fmt: (n: number) => string;
566
+ loginPath?: string;
567
+ membershipPath?: string;
568
+ }) {
379
569
  const theme = useForgeTheme();
380
570
  const [confirmEmail, setConfirmEmail] = useState("");
381
571
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -451,6 +641,50 @@ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: n
451
641
  </div>
452
642
  )}
453
643
 
644
+ {/* The refusal path. It should be unreachable now that the tier itself
645
+ is badged, but a membership can lapse mid-checkout and a page can be
646
+ served from cache — and when it happens the buyer gets the tier's
647
+ name and a way in, not a red line reading "Something went wrong."
648
+ `c.error` is deliberately not set for this case, so the two cannot
649
+ both draw. */}
650
+ <MembershipGateNotice
651
+ refusal={c.gateRefusal}
652
+ variant="card"
653
+ itemLabel="This ticket"
654
+ loginPath={loginPath}
655
+ membershipPath={membershipPath}
656
+ style={{ marginTop: 4 }}
657
+ />
658
+
659
+ {c.attendeeSlots.length > 0 && (
660
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
661
+ <div>
662
+ <h3 style={{ fontSize: 16, fontWeight: 700 }}>Who is coming?</h3>
663
+ <p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
664
+ This event prints each attendee&rsquo;s name on their own pass, so the door list matches who
665
+ actually turns up.
666
+ </p>
667
+ </div>
668
+ {c.attendeeSlots.map((slot) => (
669
+ <label
670
+ key={`${slot.ticketId}-${slot.index}`}
671
+ style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}
672
+ >
673
+ <span>
674
+ {slot.label}
675
+ {slot.isBuyer && <span style={{ opacity: 0.6 }}> (that&rsquo;s you)</span>}
676
+ </span>
677
+ <input
678
+ placeholder="Full name"
679
+ value={slot.value}
680
+ onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
681
+ style={inputStyle(theme)}
682
+ />
683
+ </label>
684
+ ))}
685
+ </div>
686
+ )}
687
+
454
688
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
455
689
  </div>
456
690
  <ActionBar
@@ -484,6 +718,10 @@ function PaymentStep({
484
718
  <div>
485
719
  <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
486
720
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
721
+ {/* The reservation clock. Event ticket holds are taken unconditionally —
722
+ no feature switch — so this is the one checkout where the countdown
723
+ fires for every profile, and it was the one that did not render it. */}
724
+ <HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
487
725
  <div
488
726
  style={{
489
727
  padding: 20,
@@ -544,7 +782,15 @@ function PaymentStep({
544
782
  </span>
545
783
  </div>
546
784
  </div>
547
- {!renderPayment ? (
785
+ {/* Paystack needs no registered renderer at all (it has its own modal),
786
+ so it is checked BEFORE the "not configured" branch. */}
787
+ {c.isPaystack ? (
788
+ c.canOpenPaystack ? (
789
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(c.totalAmount)}`} />
790
+ ) : (
791
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
792
+ )
793
+ ) : !renderPayment ? (
548
794
  <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
549
795
  ) : c.clientSecret ? (
550
796
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
@@ -754,8 +1000,8 @@ function PwywAmountField({
754
1000
  Name your price
755
1001
  <span style={{ fontWeight: 400, opacity: 0.7 }}>
756
1002
  {" "}
757
- minimum {fmt(floor)}
758
- {maximum != null ? `, up to ${fmt(maximum)}` : ""}
1003
+ (minimum {fmt(floor)}
1004
+ {maximum != null ? `, up to ${fmt(maximum)}` : ""})
759
1005
  </span>
760
1006
  </label>
761
1007
  <input
@@ -772,9 +1018,9 @@ function PwywAmountField({
772
1018
  />
773
1019
  <p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
774
1020
  {belowFloor
775
- ? `The minimum is ${fmt(floor)} we'll use that.`
1021
+ ? `The minimum is ${fmt(floor)}, so we'll use that.`
776
1022
  : aboveMax
777
- ? `The most you can pay is ${fmt(maximum!)} we'll use that.`
1023
+ ? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
778
1024
  : quantity > 1
779
1025
  ? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
780
1026
  : "Per ticket."}
@@ -783,6 +1029,25 @@ function PwywAmountField({
783
1029
  );
784
1030
  }
785
1031
 
1032
+ /**
1033
+ * The inline banner under a tier row — same shape as the expired one already
1034
+ * below it, so "why can't I buy this?" always answers itself in one place and
1035
+ * one visual language, whichever the reason.
1036
+ */
1037
+ const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
1038
+ marginTop: 12,
1039
+ padding: "8px 12px",
1040
+ borderRadius: t.cornerRadius,
1041
+ fontSize: 14,
1042
+ fontWeight: 600,
1043
+ background: a(color, 0.1),
1044
+ color,
1045
+ border: `1px solid ${a(color, 0.3)}`,
1046
+ display: "flex",
1047
+ alignItems: "center",
1048
+ gap: 8,
1049
+ });
1050
+
786
1051
  const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
787
1052
  width: 40,
788
1053
  height: 40,
@@ -168,7 +168,9 @@ function WaitlistPlaceCard({
168
168
  {offerOpen ? (
169
169
  <span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
170
170
  <BellRing size={16} />
171
- Tickets available
171
+ {/* 2.6b — "reserved for you" is a materially different promise from
172
+ "available", and the fan acts differently on each. */}
173
+ {entry.offer.seatsReserved ? "Reserved for you" : "Tickets available"}
172
174
  </span>
173
175
  ) : entry.position != null ? (
174
176
  <span style={{ fontSize: 14, opacity: 0.85 }}>
@@ -213,6 +215,9 @@ function WaitlistPlaceCard({
213
215
  */
214
216
  function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
215
217
  const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
218
+ // Absent on a payload from an older API build — read as "not reserved", which
219
+ // is the weaker and therefore safe reading.
220
+ const reserved = entry.offer.seatsReserved === true;
216
221
 
217
222
  useEffect(() => {
218
223
  setRemaining(entry.offer.secondsRemaining ?? 0);
@@ -254,8 +259,23 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
254
259
  </div>
255
260
  {deadline && (
256
261
  <div style={{ opacity: 0.8, marginTop: 4 }}>
257
- {remaining > 0 ? `Your tickets are held until ${deadline}.` : `Your offer expired at ${deadline}.`} Tickets are
258
- not reserved buy from the ticket options above to secure them.
262
+ {/*
263
+ 2.6bthe sentence is chosen by whether the seats are actually
264
+ HELD, not by whether an offer exists.
265
+
266
+ The old copy said both things at once — "your tickets are held
267
+ until Friday" and "tickets are not reserved" — because the second
268
+ was true and the first was aspirational. Now one of them is true per
269
+ row: a reserved offer really does keep the seats off the market for
270
+ the window, and a grandfathered one really does not. Saying the
271
+ strong version of an offer that reserves nothing is the exact
272
+ failure this change closes.
273
+ */}
274
+ {remaining <= 0
275
+ ? `Your offer expired at ${deadline}.`
276
+ : reserved
277
+ ? `${entry.quantity === 1 ? "Your ticket is" : `Your ${entry.quantity} tickets are`} reserved until ${deadline}. Nobody else can buy ${entry.quantity === 1 ? "it" : "them"}. Check out from the ticket options above.`
278
+ : `Your offer closes ${deadline}. Tickets are not reserved. Buy from the ticket options above to secure them.`}
259
279
  </div>
260
280
  )}
261
281
  </div>