@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,11 +1,25 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
3
  import { useCart } from "../../../contexts/CartContext";
4
- import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
4
+ import { useCreateEventOrder } from "../../../data/queries/useEvents";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
6
  import { useCouponField } from "../coupon/useCouponField";
7
+ import { usePresaleCode } from "./usePresaleCode";
8
+ import { useEventWaitlistClaim } from "../../../data/queries/useEventWaitlist";
9
+ import {
10
+ buildAttendeeSlots,
11
+ setAttendeeName,
12
+ validateAttendeeNames,
13
+ attendeesPayload,
14
+ collectsAttendees,
15
+ buyerFullName,
16
+ type AttendeeNames,
17
+ } from "../../format/attendees";
18
+ import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
19
+ import { useInventoryHold } from "../checkout/useInventoryHold";
7
20
  import { computeBookingFeeAmount } from "../../format/bookingFee";
8
21
  import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
22
+ import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
9
23
  import { readAttributionRef } from "../../../utils/attribution";
10
24
  import { readLanding } from "../../../utils/landing";
11
25
 
@@ -36,9 +50,40 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
36
50
  const { setTickets, hasTicketsFor } = useCart();
37
51
  // The detail is resolved by slug; the order is created against the real event
38
52
  // id (the orders endpoint looks the event up by id, not slug).
39
- const { data: event, isLoading } = useEvent(slug);
53
+ // Read THROUGH the presale field rather than calling `useEvent` directly: it
54
+ // is the same React Query entry, but it is the one fetched with whatever
55
+ // presale code this buyer has applied, so `event.tickets` already contains the
56
+ // tiers that code unlocked. Calling `useEvent(slug)` here instead would give a
57
+ // tier list that silently omits what the buyer just unlocked access to.
58
+ const presale = usePresaleCode(slug);
59
+ const { event, isLoading } = presale;
40
60
  const eventId = event?.id;
41
61
  const createOrder = useCreateEventOrder(eventId);
62
+ /**
63
+ * Events 2.6b — the waitlist offer this visitor arrived holding, if any.
64
+ *
65
+ * Read here rather than passed in because the whole flow needs it: the tier
66
+ * the offer covers must be ADDABLE (the offeree's own reservation is what
67
+ * makes it read sold out), and the order must carry the token that spends the
68
+ * reservation instead of trying to take a new one. Null for every ordinary
69
+ * visitor, and no request is made for them.
70
+ */
71
+ const waitlistClaim = useEventWaitlistClaim(eventId);
72
+ /**
73
+ * Seats of `held` that belong to THIS visitor, by tier.
74
+ *
75
+ * Handed to `maxAddableTickets` / `ticketSeatState` so the picker lets the
76
+ * offeree add exactly what was reserved for them and not one seat more. The
77
+ * server re-derives the same number from the hold it issued, so a tampered
78
+ * value buys nothing.
79
+ */
80
+ const reservedForYou = useMemo<Record<string, number>>(
81
+ () =>
82
+ waitlistClaim.isClaimable && waitlistClaim.entry
83
+ ? { [waitlistClaim.entry.eventTicketId]: waitlistClaim.entry.quantity }
84
+ : {},
85
+ [waitlistClaim.isClaimable, waitlistClaim.entry],
86
+ );
42
87
  const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
43
88
 
44
89
  const [step, setStep] = useState<EventCheckoutStep>("tickets");
@@ -53,8 +98,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
53
98
  const [lastName, setLastName] = useState(user?.lastName ?? "");
54
99
  const [email, setEmail] = useState(user?.email ?? "");
55
100
  const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
101
+ /**
102
+ * ticketId → per-seat names, positional.
103
+ *
104
+ * `collectAttendeeDetails` is an event setting an artist turns on for a
105
+ * guest-list show. It shipped with an admin toggle, a backend that accepts a
106
+ * per-ticket attendees map, and NO storefront asking for anything — so every
107
+ * pass printed the buyer's name and the door list was wrong, with nothing
108
+ * explaining why the setting did nothing. Both stacks render this component,
109
+ * so collecting it here is what makes the toggle real.
110
+ */
111
+ const [attendeeNames, setAttendeeNames] = useState<AttendeeNames>({});
56
112
  const [returnUrl, setReturnUrl] = useState("");
57
113
  const [error, setError] = useState<string | null>(null);
114
+ /**
115
+ * A `MEMBERSHIP_GATE` refusal, STRUCTURED — which tier, and whether the buyer
116
+ * needs to sign in or to join.
117
+ *
118
+ * The gate should have been visible on the tier long before this (the read
119
+ * announces it), so reaching here means either the buyer's membership lapsed
120
+ * mid-checkout or the page rendered from a stale cache. Either way the useful
121
+ * response is the badge's: name the tier and offer the way in. Kept as state
122
+ * rather than a message because a sentence alone turns a silent failure into a
123
+ * visible dead end.
124
+ */
125
+ const [gateRefusal, setGateRefusal] = useState<MembershipGateRefusal | null>(null);
58
126
  /**
59
127
  * The booking fee the SERVER charged, once an order exists.
60
128
  *
@@ -232,11 +300,32 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
232
300
 
233
301
  const continueToPayment = async () => {
234
302
  setError(null);
303
+ setGateRefusal(null);
235
304
  if (!slug || !eventId) return;
236
305
  if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
237
306
  setError("Please enter your name and a valid email.");
238
307
  return;
239
308
  }
309
+ /**
310
+ * Spend the reservation only when this order IS the claim.
311
+ *
312
+ * The offered tier, alone in the cart, at no more than the offered
313
+ * quantity — the same shape `resolveWaitlistClaim` accepts. Checking it
314
+ * here rather than sending the token blindly means a fan who adds a second
315
+ * tier gets an ordinary (and possibly successful) purchase instead of a
316
+ * claim rejection, which is the friendlier of the two truths.
317
+ */
318
+ const claimedTierId = waitlistClaim.entry?.eventTicketId;
319
+ const cartTiers = Object.keys(selectedTickets).filter((id) => (selectedTickets[id] ?? 0) > 0);
320
+ const waitlistClaimToken =
321
+ waitlistClaim.isClaimable &&
322
+ claimedTierId &&
323
+ cartTiers.length === 1 &&
324
+ cartTiers[0] === claimedTierId &&
325
+ (selectedTickets[claimedTierId] ?? 0) <= (waitlistClaim.entry?.quantity ?? 0)
326
+ ? waitlistClaim.token
327
+ : null;
328
+
240
329
  try {
241
330
  const data = await createOrder.mutateAsync({
242
331
  items: selectedTickets,
@@ -247,7 +336,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
247
336
  firstName,
248
337
  lastName,
249
338
  questionnaire,
339
+ // Omitted entirely when the event does not collect them, so an ordinary
340
+ // purchase posts the body it always did.
341
+ ...(attendeesPayload(attendeeSlots) ? { attendees: attendeesPayload(attendeeSlots) } : {}),
250
342
  couponCode: coupon.submittedCode,
343
+ // The presale code, not a discount. The sell guard re-checks it on the
344
+ // ORDER, so it has to travel with the purchase and not just with the
345
+ // read that revealed the tier — otherwise a buyer unlocks a presale,
346
+ // fills in their details, and is refused at the card step for want of
347
+ // the code they already typed. Omitted when there is none, so an
348
+ // ordinary purchase posts the body it always did.
349
+ ...(presale.code ? { accessCode: presale.code } : {}),
350
+ /**
351
+ * The waitlist claim (2.6b). Sent ONLY when the cart is exactly the
352
+ * offered tier: the server refuses a claim whose cart reaches past it,
353
+ * and sending one anyway would turn "you also added a T-shirt tier"
354
+ * into a claim failure rather than an ordinary purchase.
355
+ *
356
+ * Omitted entirely otherwise, so an ordinary purchase posts the body it
357
+ * always did.
358
+ */
359
+ ...(waitlistClaimToken ? { waitlistClaimToken } : {}),
251
360
  attributionRefId: readAttributionRef() ?? undefined,
252
361
  ...(readLanding() ?? {}),
253
362
  });
@@ -279,10 +388,36 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
279
388
  if (result.provider && result.provider !== "stripe") return; // Paystack redirected
280
389
  setStep("payment");
281
390
  } catch (e) {
391
+ /**
392
+ * A members-only tier the buyer cannot have. Checked BEFORE the coupon
393
+ * branch: a buyer who happened to type a discount code would otherwise be
394
+ * told the CODE was refused and sent to fix a code that was never the
395
+ * problem.
396
+ */
397
+ const gate = parseMembershipGateError(e);
398
+ if (gate) {
399
+ setGateRefusal(gate);
400
+ return;
401
+ }
282
402
  // A refused code fails the whole request, so with one entered the message
283
403
  // belongs next to the field the buyer just used. The message is the API's
284
404
  // own — "This coupon has expired", "…does not reach this coupon's minimum
285
405
  // spend" — never a house "invalid code" that hides which it was.
406
+ /**
407
+ * The reservation lapsed mid-payment (409 `INVENTORY_HOLD_EXPIRED`) —
408
+ * NOT sold out, and emphatically not a bad coupon. Checked before the
409
+ * coupon branch for the same reason the gate is: a buyer who happened to
410
+ * type a discount code would otherwise be sent to fix a code that was
411
+ * never the problem, while the one action that works — retry — is not
412
+ * offered at all.
413
+ *
414
+ * Event ticket holds are taken UNCONDITIONALLY (no feature switch), so
415
+ * unlike the product cart this path is live for every profile.
416
+ */
417
+ if (isHoldExpiredError(e)) {
418
+ setError(holdExpiredMessage(e, "Your ticket reservation expired before payment finished. Please try again."));
419
+ return;
420
+ }
286
421
  if (coupon.submittedCode) coupon.fail(e);
287
422
  else setError(errMessage(e));
288
423
  }
@@ -306,9 +441,47 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
306
441
  setStep("details");
307
442
  };
308
443
 
444
+ /**
445
+ * One slot per seat, in cart order, with the buyer pre-filled into the first.
446
+ * Empty when the event does not ask — so a normal event renders and posts
447
+ * exactly what it always did.
448
+ */
449
+ /**
450
+ * The reservation clock. `undefined` on an older API and `null` when no hold
451
+ * was taken or the order is already paid — both render nothing, i.e. exactly
452
+ * the behaviour before holds existed.
453
+ */
454
+ const holdExpiresAt = (flow.result as { holdExpiresAt?: string | null } | undefined)?.holdExpiresAt ?? null;
455
+ const hold = useInventoryHold(holdExpiresAt);
456
+
457
+ const attendeeSlots = useMemo(
458
+ () =>
459
+ collectsAttendees(event)
460
+ ? buildAttendeeSlots({
461
+ tickets: (event?.tickets ?? []).filter((t) => (selectedTickets[t.id] ?? 0) > 0),
462
+ quantities: selectedTickets,
463
+ names: attendeeNames,
464
+ buyerName: buyerFullName({ firstName, lastName }),
465
+ })
466
+ : [],
467
+ [event, selectedTickets, attendeeNames, firstName, lastName],
468
+ );
469
+
470
+ const attendeeCheck = useMemo(() => validateAttendeeNames(attendeeSlots), [attendeeSlots]);
471
+
472
+ const setAttendee = (ticketId: string, index: number, value: string) =>
473
+ setAttendeeNames((prev) => setAttendeeName(prev, ticketId, index, value));
474
+
309
475
  return {
310
476
  event,
311
477
  isLoading,
478
+ /** Countdown on the ticket reservation. `active:false` when there is none. */
479
+ hold,
480
+ /** Per-seat name inputs. Empty array when the event does not collect them. */
481
+ attendeeSlots,
482
+ setAttendee,
483
+ /** `{ok:false}` while a required seat name is missing or too long. */
484
+ attendeeCheck,
312
485
  step,
313
486
  setStep,
314
487
  selectedTickets,
@@ -354,6 +527,22 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
354
527
  /** Discount code state + the server's quote. See `useCouponField`. */
355
528
  coupon,
356
529
  clearCoupon,
530
+ /**
531
+ * Presale-code state (1.2) — a DIFFERENT code from `coupon`: it buys access
532
+ * to a tier, it does not change a price. `presale.visible` is false on every
533
+ * event without a coded tier, so a surface can render it unconditionally.
534
+ */
535
+ presale,
536
+ /**
537
+ * Events 2.6b — the waitlist offer this visitor arrived holding.
538
+ *
539
+ * `reservedForYou` is the per-tier seat count a surface must pass to
540
+ * `maxAddableTickets` / `ticketSeatState`, or the offeree cannot add the
541
+ * seat that is being held for them. `waitlistClaim.isClaimable` is what a
542
+ * surface renders "your tickets are reserved until…" off.
543
+ */
544
+ waitlistClaim,
545
+ reservedForYou,
357
546
  /** Authoritative sales-tax quote from start-payment (display only). */
358
547
  taxQuote: flow.result?.taxQuote ?? null,
359
548
  firstName,
@@ -370,8 +559,20 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
370
559
  ticketsInCart: event ? hasTicketsFor(event.id) : false,
371
560
  continueToPayment,
372
561
  clientSecret: flow.clientSecret,
562
+ /** Paystack: the started inline-checkout session and the way back into it. */
563
+ isPaystack: flow.isPaystack,
564
+ canOpenPaystack: flow.canOpenPaystack,
565
+ openPaystackCheckout: flow.openPaystackCheckout,
373
566
  returnUrl,
374
567
  isProcessing: createOrder.isPending || flow.isStarting,
375
568
  error,
569
+ /**
570
+ * A `MEMBERSHIP_GATE` refusal, structured — pass it to
571
+ * `<MembershipGateNotice refusal={…}>` (or `useMembershipGateNotice`) rather
572
+ * than printing `error`, which is deliberately NOT set for this case: the
573
+ * buyer needs the tier's name and a way in, and a generic red line gives
574
+ * them neither.
575
+ */
576
+ gateRefusal,
376
577
  };
377
578
  }
@@ -0,0 +1,181 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
2
+ import type { IEvent } from "../../../types/models";
3
+ import { useEvent } from "../../../data/queries/useEvents";
4
+ import {
5
+ readPresaleCode,
6
+ readPresaleCodeFromUrl,
7
+ subscribePresaleCode,
8
+ writePresaleCode,
9
+ } from "../../../utils/presaleCode";
10
+
11
+ export type PresaleCodeStatus = "idle" | "checking" | "accepted" | "rejected";
12
+
13
+ export interface UsePresaleCodeOptions {
14
+ /**
15
+ * The event as a route loader already fetched it (uncoded). Seeds the read so
16
+ * a server-rendered page paints without a spinner, exactly as
17
+ * `useEvent`'s own `initialData` does.
18
+ */
19
+ initialEvent?: IEvent;
20
+ }
21
+
22
+ export interface PresaleCodeField {
23
+ /**
24
+ * The event AS THIS BUYER MAY SEE IT — refetched with the applied code, so
25
+ * `tickets` already contains whatever that code unlocked.
26
+ *
27
+ * Read this rather than calling `useEvent` separately; both resolve to the
28
+ * same React Query entry, but only this one is guaranteed to be the coded
29
+ * read.
30
+ */
31
+ event?: IEvent;
32
+ isLoading: boolean;
33
+ /**
34
+ * Render a code box at all?
35
+ *
36
+ * FALSE for every event with no presale — which is nearly all of them, and
37
+ * the reason this is a server fact rather than "always show it". A box on an
38
+ * event with no coded tier sends the buyer hunting for a code that does not
39
+ * exist.
40
+ */
41
+ visible: boolean;
42
+ /** The text in the input — the applied code until the buyer edits it. */
43
+ input: string;
44
+ setInput: (value: string) => void;
45
+ /**
46
+ * The code currently APPLIED: what the event above was fetched with, and what
47
+ * the order must carry. `null` until one is submitted.
48
+ */
49
+ code: string | null;
50
+ status: PresaleCodeStatus;
51
+ /**
52
+ * The message to put under the field, or `null` for none.
53
+ *
54
+ * A refusal names no tier and confirms no guess: the buyer learns their code
55
+ * is not one for this event and nothing else, or the box becomes a way to
56
+ * enumerate codes.
57
+ */
58
+ message: string | null;
59
+ /** Apply what is in the input. No-op on an empty field. */
60
+ submit: () => void;
61
+ /** Drop the applied code — re-hides whatever it revealed. */
62
+ clear: () => void;
63
+ /** Whether the applied code opened something. */
64
+ isUnlocked: boolean;
65
+ }
66
+
67
+ /**
68
+ * The presale-code entry on an event page (Events 1.2).
69
+ *
70
+ * ## The gap this closes
71
+ *
72
+ * 1.2 shipped hidden tiers, an access-code column, SQL that reveals a tier to
73
+ * the right code, and a sell guard that enforces it at checkout — and no
74
+ * storefront on either rendering stack had an input. An artist could author a
75
+ * presale and mail the code to their list, and the recipient had nowhere to put
76
+ * it. The feature was unreachable by the only person it is for.
77
+ *
78
+ * ## Two facts, both of which have to come from the server
79
+ *
80
+ * `presale.hasCodedTiers` decides whether the box exists. A client cannot work
81
+ * it out: a hidden tier is filtered out of the response, so a presale-only
82
+ * event is indistinguishable from an event with no tickets yet.
83
+ *
84
+ * `presale.codeAccepted` decides what the box SAYS. Diffing the tier list
85
+ * before and after would look like it works and then fail on the case that
86
+ * matters — a tier that is VISIBLE but code-gated is already listed, so the
87
+ * correct code changes nothing on screen and the diff would call it wrong.
88
+ *
89
+ * ## Persistence
90
+ *
91
+ * The applied code is stored per event in `sessionStorage` (see
92
+ * `utils/presaleCode`), so it survives the reload, the payment redirect and the
93
+ * back button, and so the page and the ticket modal — separate components each
94
+ * with their own `useEvent` — always agree on it. Without that, a buyer unlocks
95
+ * a tier, walks to checkout, and the order is refused for want of the code they
96
+ * already typed. `?accessCode=` on the URL seeds it once, which is what makes a
97
+ * mailed presale link work on arrival.
98
+ *
99
+ * ```tsx
100
+ * const presale = usePresaleCode(slug);
101
+ * // presale.event already has the unlocked tiers in it
102
+ * ```
103
+ */
104
+ export function usePresaleCode(eventKey?: string, options: UsePresaleCodeOptions = {}): PresaleCodeField {
105
+ const key = eventKey ?? "";
106
+
107
+ const code = useSyncExternalStore(
108
+ subscribePresaleCode,
109
+ () => readPresaleCode(key),
110
+ // Server render: no storage, so nothing is applied. The first client render
111
+ // picks up a stored code and refetches with it.
112
+ () => null,
113
+ );
114
+
115
+ /** `null` = untouched, so the field shows the applied code until it is edited. */
116
+ const [draft, setDraft] = useState<string | null>(null);
117
+
118
+ // A mailed presale link lands with the code already on it. Seeded once, and
119
+ // never over an applied code — a buyer who cleared one did so on purpose.
120
+ useEffect(() => {
121
+ if (!key || readPresaleCode(key)) return;
122
+ const fromUrl = readPresaleCodeFromUrl();
123
+ if (fromUrl) writePresaleCode(key, fromUrl);
124
+ }, [key]);
125
+
126
+ const { data: event, isLoading, isFetching } = useEvent(eventKey, {
127
+ accessCode: code,
128
+ initialData: options.initialEvent,
129
+ });
130
+
131
+ const presale = event?.presale;
132
+
133
+ // While a newly applied code is in flight the event on hand is still the
134
+ // PREVIOUS response (kept deliberately, so the page does not blank), and its
135
+ // verdict describes the old code. Reporting it would flash "not valid" at a
136
+ // buyer whose code is about to be accepted.
137
+ //
138
+ // `!presale` is an API that does not report presale state at all (an older
139
+ // deployment, or the list endpoint's shape). Silence, not a spinner that
140
+ // never resolves.
141
+ const status: PresaleCodeStatus = !code || !presale
142
+ ? "idle"
143
+ : isFetching || presale.codeAccepted === null
144
+ ? "checking"
145
+ : presale.codeAccepted
146
+ ? "accepted"
147
+ : "rejected";
148
+
149
+ const submit = useCallback(() => {
150
+ const trimmed = (draft ?? "").trim();
151
+ if (!trimmed || !key) return;
152
+ writePresaleCode(key, trimmed);
153
+ }, [draft, key]);
154
+
155
+ const clear = useCallback(() => {
156
+ if (!key) return;
157
+ setDraft("");
158
+ writePresaleCode(key, null);
159
+ }, [key]);
160
+
161
+ return {
162
+ event,
163
+ isLoading,
164
+ visible: !!presale?.hasCodedTiers,
165
+ input: draft ?? code ?? "",
166
+ setInput: setDraft,
167
+ code,
168
+ status,
169
+ message:
170
+ status === "accepted"
171
+ ? "Code applied."
172
+ : // Says only that the code is not one for this event. Naming a tier, or
173
+ // hinting that one exists, would turn the box into a way to guess codes.
174
+ status === "rejected"
175
+ ? "That code isn't valid for this event."
176
+ : null,
177
+ submit,
178
+ clear,
179
+ isUnlocked: status === "accepted",
180
+ };
181
+ }
@@ -4,6 +4,11 @@ export * from "./dialog";
4
4
  export * from "./donation";
5
5
  export * from "./offer";
6
6
  export { MembershipGate, type MembershipGateProps, type MembershipGateState } from "./membership/MembershipGate";
7
+ // S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
8
+ export {
9
+ useMembershipGateNotice,
10
+ type UseMembershipGateNoticeInput,
11
+ } from "./membership/useMembershipGateNotice";
7
12
  export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
8
13
  export { useContactForm } from "./forms/useContactForm";
9
14
  export { useSectionedForm } from "./forms/useSectionedForm";
@@ -17,12 +22,32 @@ export {
17
22
  type CheckoutShippingData,
18
23
  type SelectedShippingRate,
19
24
  } from "./checkout/useCheckout";
25
+ // Inventory holds — the reservation a buyer is given while they pay, and the
26
+ // 409 they get when it lapses. Shared by both rendering stacks.
27
+ export {
28
+ useInventoryHold,
29
+ type InventoryHoldState,
30
+ type UseInventoryHoldOptions,
31
+ } from "./checkout/useInventoryHold";
32
+ export {
33
+ isHoldExpiredError,
34
+ holdExpiredMessage,
35
+ holdMsRemaining,
36
+ formatHoldRemaining,
37
+ HOLD_EXPIRED_CODE,
38
+ } from "./checkout/inventoryHold";
20
39
  export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
21
40
  export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
22
41
 
23
42
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
24
43
  // each composed from the data hooks so a creator can rebuild any page.
25
44
  export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
45
+ export {
46
+ usePresaleCode,
47
+ type PresaleCodeField,
48
+ type PresaleCodeStatus,
49
+ type UsePresaleCodeOptions,
50
+ } from "./event/usePresaleCode";
26
51
  export {
27
52
  useCouponField,
28
53
  apiErrorMessage,
@@ -11,8 +11,9 @@ export interface UseInvoicePaymentOptions {
11
11
  /**
12
12
  * Headless invoice payment: loads the invoice, starts its payment, and exposes
13
13
  * the PDF download. Composes `useInvoice`, `useDownloadInvoicePdf`,
14
- * `usePaymentFlow`. Stripe UI renders from `clientSecret`; finalize on the
15
- * return page with `useInvoiceFinalize`.
14
+ * `usePaymentFlow`. Stripe UI renders from `clientSecret`; Paystack opens its
15
+ * inline modal (and `openPaystackCheckout` re-opens it after a dismissal).
16
+ * Finalize on the return page with `useInvoiceFinalize`.
16
17
  */
17
18
  export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOptions = {}) {
18
19
  const invoice = useInvoice(invoiceId);
@@ -35,6 +36,10 @@ export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOpt
35
36
  clientSecret: flow.clientSecret,
36
37
  paymentId: flow.paymentId,
37
38
  provider: flow.provider,
39
+ /** Paystack: the started inline-checkout session and the way back into it. */
40
+ isPaystack: flow.isPaystack,
41
+ canOpenPaystack: flow.canOpenPaystack,
42
+ openPaystackCheckout: flow.openPaystackCheckout,
38
43
  /** Sales-tax quote (built from the invoice's own tax columns — no re-quote). */
39
44
  taxQuote: flow.result?.taxQuote ?? null,
40
45
  isStarting: flow.isStarting,
@@ -8,6 +8,12 @@ import {
8
8
  } from "../../../data/queries/useSubscriptions";
9
9
  import { readAttributionRef } from "../../../utils/attribution";
10
10
  import { readLanding } from "../../../utils/landing";
11
+ import {
12
+ hasPaystackSession,
13
+ openPaystackCheckout as openPaystackModal,
14
+ type PaystackCheckoutOutcome,
15
+ type PaystackCheckoutSession,
16
+ } from "../../../utils/paystackCheckout";
11
17
 
12
18
  export type MembershipCheckoutStep = "select" | "payment";
13
19
  export type BillingCycle = "month" | "year";
@@ -15,7 +21,7 @@ export type BillingCycle = "month" | "year";
15
21
  export interface UseMembershipCheckoutOptions {
16
22
  /** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
17
23
  initialTierId?: string;
18
- /** Where Stripe returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
24
+ /** Where the provider returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
19
25
  returnPath?: string;
20
26
  /** Where free activations land. Default `/i/account?tab=membership`. */
21
27
  freeReturnPath?: string;
@@ -35,6 +41,11 @@ const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; pric
35
41
  * subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
36
42
  * `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
37
43
  * Paid flow exposes `clientSecret` for `ForgePaymentProvider`.
44
+ *
45
+ * Three outcomes, not two: a member who ALREADY subscribes to this artist is
46
+ * changing tier, which the server settles by proration on their existing
47
+ * subscription. That completes server-side with no payment step — it is
48
+ * finished the moment `subscribe()` resolves, exactly like a free activation.
38
49
  */
39
50
  export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
40
51
  const { user } = usePublicAuth();
@@ -48,6 +59,8 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
48
59
  const [billingCycle, setBillingCycle] = useState<BillingCycle>("month");
49
60
  const [customAmount, setCustomAmount] = useState<number>(0);
50
61
  const [clientSecret, setClientSecret] = useState<string | undefined>();
62
+ /** The started Paystack checkout, kept so the modal can be re-opened. */
63
+ const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
51
64
  const [error, setError] = useState<string | null>(null);
52
65
 
53
66
  const selectedTier = useMemo(
@@ -72,6 +85,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
72
85
  return;
73
86
  }
74
87
  const origin = typeof window !== "undefined" ? window.location.origin : "";
88
+ const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
75
89
  const attributionRefId = readAttributionRef() ?? undefined;
76
90
  const landing = readLanding() ?? {};
77
91
  try {
@@ -87,10 +101,46 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
87
101
  amount,
88
102
  billingCycle,
89
103
  membershipTierId: selectedTier.id,
90
- returnUrl: `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`,
104
+ returnUrl,
91
105
  attributionRefId,
92
106
  ...landing,
93
107
  });
108
+
109
+ // A member who already subscribes is CHANGING TIER: the server moved their
110
+ // existing subscription onto the new price with proration, so there is
111
+ // nothing to pay and no card to collect. Sending them to a payment step
112
+ // with no client secret is a form that can never be completed.
113
+ // Every provider session counts, not just Stripe's secret. Reading a
114
+ // missing `clientSecret` as "nothing to pay" would activate a Paystack
115
+ // membership nobody has paid for.
116
+ const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
117
+ if (!requiresPayment) {
118
+ if (opts.onComplete) opts.onComplete();
119
+ else if (typeof window !== "undefined") {
120
+ window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
121
+ }
122
+ return;
123
+ }
124
+
125
+ if (data.accessCode || data.checkoutUrl) {
126
+ // Paystack: the fan pays in a modal over this page. The helper navigates
127
+ // to `returnUrl` on success, the same return leg the hosted redirect
128
+ // used, so `confirmLatest` still reconciles on arrival.
129
+ const session: PaystackCheckoutSession = {
130
+ accessCode: data.accessCode,
131
+ checkoutUrl: data.checkoutUrl,
132
+ returnUrl,
133
+ };
134
+ setPaystackSession(session);
135
+ setStep("payment");
136
+ void openPaystackModal(session, {
137
+ // Closing the popup leaves the subscription unpaid and re-openable.
138
+ onDismiss: () => {},
139
+ onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
140
+ });
141
+ return;
142
+ }
143
+
94
144
  setClientSecret(data.clientSecret);
95
145
  setStep("payment");
96
146
  } catch (e) {
@@ -113,6 +163,17 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
113
163
  amount,
114
164
  subscribe,
115
165
  clientSecret,
166
+ /** True once the subscription started on Paystack rather than Stripe. */
167
+ isPaystack: !!paystackSession,
168
+ canOpenPaystack: hasPaystackSession(paystackSession),
169
+ /** Re-open the Paystack modal on the same subscription charge. */
170
+ openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
171
+ paystackSession
172
+ ? openPaystackModal(paystackSession, {
173
+ onDismiss: () => {},
174
+ onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
175
+ })
176
+ : Promise.resolve("unavailable" as PaystackCheckoutOutcome),
116
177
  isProcessing: createPaid.isPending || createFree.isPending,
117
178
  error,
118
179
  /** Call on the return page to reconcile the latest subscription. */