@tribe-nest/forge 3.21.0 → 3.22.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useEvents.ts +66 -4
  4. package/src/data/queries/useMembership.ts +8 -2
  5. package/src/data/queries/useMyBookings.ts +12 -0
  6. package/src/data/queries/useMyTickets.ts +112 -0
  7. package/src/data/queries/useOrders.ts +10 -0
  8. package/src/data/queries/usePassTransfers.ts +73 -13
  9. package/src/server/index.ts +52 -0
  10. package/src/types/models.ts +151 -0
  11. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  12. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  13. package/src/ui/format/attendees.ts +187 -0
  14. package/src/ui/format/membershipGate.ts +209 -0
  15. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  16. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  17. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  18. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  19. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  20. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  22. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  23. package/src/ui/headless/index.ts +25 -0
  24. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  25. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  26. package/src/ui/index.ts +42 -0
  27. package/src/ui/styled/AccountDashboard.tsx +70 -8
  28. package/src/ui/styled/AddToCalendar.tsx +34 -10
  29. package/src/ui/styled/Checkout.tsx +18 -1
  30. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  31. package/src/ui/styled/CourseDetail.tsx +30 -1
  32. package/src/ui/styled/EventConfirmation.tsx +2 -0
  33. package/src/ui/styled/EventDetail.tsx +53 -22
  34. package/src/ui/styled/EventTickets.tsx +156 -5
  35. package/src/ui/styled/HoldNotice.tsx +192 -0
  36. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  37. package/src/ui/styled/OfferButton.tsx +23 -0
  38. package/src/ui/styled/PresaleCode.tsx +174 -0
  39. package/src/ui/styled/ProductDetail.tsx +75 -5
  40. package/src/ui/styled/ProductGrid.tsx +26 -0
  41. package/src/ui/styled/TicketTransfer.tsx +69 -40
  42. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  43. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  44. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  45. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  46. package/src/utils/_tests/structuredData.spec.ts +275 -0
  47. package/src/utils/presaleCode.ts +96 -0
  48. package/src/utils/structuredData.ts +361 -27
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The inventory hold, as a buyer experiences it.
3
+ *
4
+ * The server reserves stock while a buyer is on the card form and reports when
5
+ * that reservation lapses (`holdExpiresAt`, ISO-8601, on every checkout-create
6
+ * and every `start-payment` response). When it lapses before the payment lands,
7
+ * `start-payment` answers **409 `INVENTORY_HOLD_EXPIRED`** — deliberately NOT
8
+ * the sold-out error, because "your reservation ran out" and "there are none
9
+ * left" lead the buyer to different next actions and only the first one has a
10
+ * retry that works.
11
+ *
12
+ * None of that was rendered anywhere. This module is the shared, provider-free
13
+ * half of rendering it, so both stacks (the client PWA and Forge's own styled
14
+ * drop-ins) count the same clock and read the same error.
15
+ *
16
+ * Everything here is pure — no React, no provider tree — so it is unit-testable
17
+ * on its own, the same reason `bundleCoupon.ts` sits beside it.
18
+ */
19
+
20
+ /** The machine-readable code the API stamps on an expired-hold refusal. */
21
+ export const HOLD_EXPIRED_CODE = "INVENTORY_HOLD_EXPIRED";
22
+
23
+ type MaybeApiError = {
24
+ response?: { status?: number; data?: { code?: string; message?: string } };
25
+ };
26
+
27
+ /**
28
+ * Is this failure "your reservation lapsed" rather than anything else?
29
+ *
30
+ * Switches on `code`, never on prose and never on the bare 409 — a 409 alone is
31
+ * a generic conflict a future endpoint could reuse, and treating one as an
32
+ * expired hold would offer a retry that cannot help.
33
+ */
34
+ export function isHoldExpiredError(error: unknown): boolean {
35
+ return (error as MaybeApiError)?.response?.data?.code === HOLD_EXPIRED_CODE;
36
+ }
37
+
38
+ /**
39
+ * The API's own words for a lapsed hold, already localised server-side
40
+ * (`errors.order.hold_expired` / `errors.event.hold_expired`).
41
+ *
42
+ * The fallback is only reached if the response carried no message at all; it is
43
+ * never used to *replace* the server's message, which is the one that names the
44
+ * right noun ("basket" vs "tickets").
45
+ */
46
+ export function holdExpiredMessage(error: unknown, fallback: string): string {
47
+ return (error as MaybeApiError)?.response?.data?.message || fallback;
48
+ }
49
+
50
+ /**
51
+ * Milliseconds until the hold lapses, or `null` when there is no hold.
52
+ *
53
+ * `null` and `0` are different facts and the UI must not conflate them:
54
+ * `holdExpiresAt` is absent for a free/already-settled order and for every
55
+ * profile with the holds switch off, and inventing a countdown there would
56
+ * promise a reservation nobody took. Zero means a real hold that has run out.
57
+ *
58
+ * Clamped at zero so a stale or clock-skewed instant reads as lapsed rather
59
+ * than as a negative timer.
60
+ */
61
+ export function holdMsRemaining(holdExpiresAt: string | null | undefined, now: number): number | null {
62
+ if (!holdExpiresAt) return null;
63
+ const ms = Date.parse(holdExpiresAt);
64
+ if (Number.isNaN(ms)) return null;
65
+ return Math.max(0, ms - now);
66
+ }
67
+
68
+ /**
69
+ * `m:ss` for the countdown, and `h:mm:ss` above an hour.
70
+ *
71
+ * Rounds UP, so a hold with 1ms left reads "0:01" and the timer only shows
72
+ * "0:00" at the instant it is genuinely over — a countdown that sits on zero
73
+ * for a whole second while payment is still accepted is a small lie the buyer
74
+ * acts on.
75
+ */
76
+ export function formatHoldRemaining(msRemaining: number): string {
77
+ const total = Math.ceil(Math.max(0, msRemaining) / 1000);
78
+ const seconds = total % 60;
79
+ const minutes = Math.floor(total / 60) % 60;
80
+ const hours = Math.floor(total / 3600);
81
+ const mm = hours > 0 ? String(minutes).padStart(2, "0") : String(minutes);
82
+ return `${hours > 0 ? `${hours}:` : ""}${mm}:${String(seconds).padStart(2, "0")}`;
83
+ }
@@ -18,6 +18,8 @@ import {
18
18
  type ApplyCheckoutCouponResult,
19
19
  } from "../../../data/queries/useCheckouts";
20
20
  import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
21
+ import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
22
+ import { useInventoryHold } from "./useInventoryHold";
21
23
  import { readAttributionRef } from "../../../utils/attribution";
22
24
  import { readLanding } from "../../../utils/landing";
23
25
  import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
@@ -289,6 +291,58 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
289
291
 
290
292
  const orderId = created?.orderId ?? null;
291
293
 
294
+ // ── Inventory hold ──────────────────────────────────────────────────────────
295
+ /**
296
+ * The reservation clock the buyer is counting down.
297
+ *
298
+ * `start-payment` RESTARTS the hold and reports the new instant, so its figure
299
+ * supersedes the one `createOrder` returned — that one was measured before the
300
+ * buyer had even seen a payment field. The create-time value bridges the
301
+ * moment in between, which for a slow `start-payment` is the whole of the
302
+ * buyer's first impression of the page.
303
+ *
304
+ * A bundle has no create-time figure at all (`POST /public/checkouts` takes
305
+ * the holds but does not report a window), so it is start-payment or nothing —
306
+ * which is fine, because a bundle always starts payment immediately.
307
+ *
308
+ * `undefined` on an older API and `null` whenever no hold was taken; both mean
309
+ * "render nothing", which is exactly today's behaviour.
310
+ */
311
+ const holdExpiresAt = flow.result?.holdExpiresAt ?? created?.holdExpiresAt ?? null;
312
+ const hold = useInventoryHold(holdExpiresAt);
313
+
314
+ /**
315
+ * The reservation ran out before the payment landed (409
316
+ * `INVENTORY_HOLD_EXPIRED`), which is NOT sold-out — the items may well still
317
+ * be there, and re-running `start-payment` re-acquires the very same units
318
+ * when they are. Told apart from every other start failure so the buyer is
319
+ * offered the retry that actually works instead of a dead end.
320
+ */
321
+ const isHoldExpired = isHoldExpiredError(flow.error);
322
+ const holdExpiredError = isHoldExpired
323
+ ? holdExpiredMessage(flow.error, "Your basket reservation expired before payment finished. Please try again.")
324
+ : "";
325
+
326
+ /**
327
+ * Take the reservation again on the SAME order or bundle.
328
+ *
329
+ * The cheapest recovery there is: nothing about the cart has changed, so if
330
+ * the units are still free the buyer gets a fresh window and the card form
331
+ * they were already looking at. If they are genuinely gone, another 409 says
332
+ * so — and that is the point at which "change your selection" is the honest
333
+ * next step, not before.
334
+ */
335
+ const retryHold = useCallback(async () => {
336
+ if (!created?.orderId && !checkoutId) return;
337
+ try {
338
+ await flow.start();
339
+ } catch {
340
+ // The refusal is already on `flow.error`; a rethrow here would only turn a
341
+ // handled state into an unhandled rejection.
342
+ }
343
+ // eslint-disable-next-line react-hooks/exhaustive-deps
344
+ }, [created?.orderId, checkoutId]);
345
+
292
346
  // A coupon re-issues the payment intent, so its update supersedes the initial
293
347
  // start-payment result. Otherwise use the started payment once both the order
294
348
  // and start-payment have resolved.
@@ -589,6 +643,24 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
589
643
  ticketItems,
590
644
  isCreatingOrder: createOrder.isPending || flow.isStarting,
591
645
  startError,
646
+ // ── Inventory hold ─────────────────────────────────────────────────────────
647
+ /**
648
+ * The live reservation: `isHeld` + `label` while it runs, `hasLapsed` once
649
+ * the clock hits zero. Every field is null/false when no hold was taken, so
650
+ * a surface guarding on `isHeld || hasLapsed` is unchanged for the profiles
651
+ * that have holds switched off.
652
+ */
653
+ hold,
654
+ /**
655
+ * The server said the reservation expired (409 `INVENTORY_HOLD_EXPIRED`) —
656
+ * its own words, already localised. Empty for every other failure, so this
657
+ * can never be mistaken for sold-out.
658
+ */
659
+ holdExpiredError,
660
+ isHoldExpired,
661
+ /** Re-take the reservation on the same order/bundle. See `retryHold`. */
662
+ retryHold,
663
+ isRetryingHold: flow.isStarting,
592
664
  provider,
593
665
  isPaystack: provider === PaymentProviderName.Paystack,
594
666
  paymentSecret: effectivePayment?.paymentSecret,
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { formatHoldRemaining, holdMsRemaining } from "./inventoryHold";
3
+
4
+ export interface InventoryHoldState {
5
+ /**
6
+ * True only while a real reservation is running. False both when there is no
7
+ * hold at all and once one has run out — the two are told apart by
8
+ * `hasLapsed`, and no surface should draw a timer off this alone.
9
+ */
10
+ isHeld: boolean;
11
+ /** True when a hold existed and its clock reached zero on this device. */
12
+ hasLapsed: boolean;
13
+ /** The instant the API reported, unchanged; `null` when no hold was taken. */
14
+ expiresAt: string | null;
15
+ /** Milliseconds left, or `null` when there is no hold. */
16
+ msRemaining: number | null;
17
+ /** Whole seconds left, or `null` when there is no hold. */
18
+ secondsRemaining: number | null;
19
+ /** `m:ss` (or `h:mm:ss`) for display; `null` when there is nothing to count. */
20
+ label: string | null;
21
+ /** Under two minutes — the point at which a surface should stop being subtle. */
22
+ isUrgent: boolean;
23
+ }
24
+
25
+ export interface UseInventoryHoldOptions {
26
+ /**
27
+ * Fired once, when a running clock reaches zero. For telling a flow to stop
28
+ * describing the reservation as live; NOT for tearing the payment form down.
29
+ */
30
+ onLapse?: () => void;
31
+ /** Tick interval, ms. Only for tests — a second is what a countdown means. */
32
+ intervalMs?: number;
33
+ }
34
+
35
+ /**
36
+ * Count down an inventory hold.
37
+ *
38
+ * ## What it does at zero, and why
39
+ *
40
+ * It stops, and it flips `hasLapsed`. A timer that reaches 0:00 and then keeps
41
+ * sitting there is worse than no timer: it has told the buyer something has
42
+ * happened and then refuses to say what. So the honest end state is a *changed
43
+ * statement* — "your reservation has ended" — which the calling surface renders
44
+ * in place of the clock, with the same recovery action a 409 gets.
45
+ *
46
+ * It deliberately does **not** hide or disable the payment form. This clock is
47
+ * the buyer's device clock against an instant the server chose; it can be
48
+ * skewed, and the server may still accept the payment (`start-payment`
49
+ * re-acquires the same units when they are still free). Refusing a charge the
50
+ * server would have taken loses a real sale to defend a display detail. The
51
+ * authority stays where it belongs — the 409, when it comes.
52
+ *
53
+ * ## Degrading to today's behaviour
54
+ *
55
+ * Holds are behind a per-profile switch that most profiles have off, and a free
56
+ * or already-settled order never carries one either. In all of those cases
57
+ * `holdExpiresAt` is `null`/absent, every field here is null/false, and a
58
+ * surface that guards on `isHeld || hasLapsed` renders exactly what it renders
59
+ * today.
60
+ */
61
+ export function useInventoryHold(
62
+ holdExpiresAt: string | null | undefined,
63
+ opts: UseInventoryHoldOptions = {},
64
+ ): InventoryHoldState {
65
+ const { onLapse, intervalMs = 1000 } = opts;
66
+ const [msRemaining, setMsRemaining] = useState<number | null>(() => holdMsRemaining(holdExpiresAt, Date.now()));
67
+ // Keeps the callback out of the effect's dependencies, so an inline arrow
68
+ // from the caller does not restart the interval on every render.
69
+ const onLapseRef = useRef(onLapse);
70
+ onLapseRef.current = onLapse;
71
+
72
+ useEffect(() => {
73
+ const initial = holdMsRemaining(holdExpiresAt, Date.now());
74
+ setMsRemaining(initial);
75
+ // No hold, or one that was already over when we first saw it (a resumed tab,
76
+ // a skewed clock): nothing to tick. The already-over case still reports
77
+ // `hasLapsed` below — it just never animated down to it.
78
+ if (initial === null || initial <= 0) return;
79
+
80
+ const id = setInterval(() => {
81
+ const next = holdMsRemaining(holdExpiresAt, Date.now());
82
+ setMsRemaining(next);
83
+ if (next !== null && next <= 0) {
84
+ clearInterval(id);
85
+ onLapseRef.current?.();
86
+ }
87
+ }, intervalMs);
88
+ return () => clearInterval(id);
89
+ }, [holdExpiresAt, intervalMs]);
90
+
91
+ const hasHold = msRemaining !== null;
92
+ const hasLapsed = hasHold && msRemaining <= 0;
93
+ const isHeld = hasHold && msRemaining > 0;
94
+
95
+ return {
96
+ isHeld,
97
+ hasLapsed,
98
+ expiresAt: holdExpiresAt ?? null,
99
+ msRemaining,
100
+ secondsRemaining: hasHold ? Math.ceil(msRemaining / 1000) : null,
101
+ label: isHeld ? formatHoldRemaining(msRemaining) : null,
102
+ isUrgent: isHeld && msRemaining <= 120_000,
103
+ };
104
+ }
@@ -1,11 +1,24 @@
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 {
9
+ buildAttendeeSlots,
10
+ setAttendeeName,
11
+ validateAttendeeNames,
12
+ attendeesPayload,
13
+ collectsAttendees,
14
+ buyerFullName,
15
+ type AttendeeNames,
16
+ } from "../../format/attendees";
17
+ import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
18
+ import { useInventoryHold } from "../checkout/useInventoryHold";
7
19
  import { computeBookingFeeAmount } from "../../format/bookingFee";
8
20
  import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
21
+ import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
9
22
  import { readAttributionRef } from "../../../utils/attribution";
10
23
  import { readLanding } from "../../../utils/landing";
11
24
 
@@ -36,7 +49,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
36
49
  const { setTickets, hasTicketsFor } = useCart();
37
50
  // The detail is resolved by slug; the order is created against the real event
38
51
  // id (the orders endpoint looks the event up by id, not slug).
39
- const { data: event, isLoading } = useEvent(slug);
52
+ // Read THROUGH the presale field rather than calling `useEvent` directly: it
53
+ // is the same React Query entry, but it is the one fetched with whatever
54
+ // presale code this buyer has applied, so `event.tickets` already contains the
55
+ // tiers that code unlocked. Calling `useEvent(slug)` here instead would give a
56
+ // tier list that silently omits what the buyer just unlocked access to.
57
+ const presale = usePresaleCode(slug);
58
+ const { event, isLoading } = presale;
40
59
  const eventId = event?.id;
41
60
  const createOrder = useCreateEventOrder(eventId);
42
61
  const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
@@ -53,8 +72,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
53
72
  const [lastName, setLastName] = useState(user?.lastName ?? "");
54
73
  const [email, setEmail] = useState(user?.email ?? "");
55
74
  const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
75
+ /**
76
+ * ticketId → per-seat names, positional.
77
+ *
78
+ * `collectAttendeeDetails` is an event setting an artist turns on for a
79
+ * guest-list show. It shipped with an admin toggle, a backend that accepts a
80
+ * per-ticket attendees map, and NO storefront asking for anything — so every
81
+ * pass printed the buyer's name and the door list was wrong, with nothing
82
+ * explaining why the setting did nothing. Both stacks render this component,
83
+ * so collecting it here is what makes the toggle real.
84
+ */
85
+ const [attendeeNames, setAttendeeNames] = useState<AttendeeNames>({});
56
86
  const [returnUrl, setReturnUrl] = useState("");
57
87
  const [error, setError] = useState<string | null>(null);
88
+ /**
89
+ * A `MEMBERSHIP_GATE` refusal, STRUCTURED — which tier, and whether the buyer
90
+ * needs to sign in or to join.
91
+ *
92
+ * The gate should have been visible on the tier long before this (the read
93
+ * announces it), so reaching here means either the buyer's membership lapsed
94
+ * mid-checkout or the page rendered from a stale cache. Either way the useful
95
+ * response is the badge's: name the tier and offer the way in. Kept as state
96
+ * rather than a message because a sentence alone turns a silent failure into a
97
+ * visible dead end.
98
+ */
99
+ const [gateRefusal, setGateRefusal] = useState<MembershipGateRefusal | null>(null);
58
100
  /**
59
101
  * The booking fee the SERVER charged, once an order exists.
60
102
  *
@@ -232,6 +274,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
232
274
 
233
275
  const continueToPayment = async () => {
234
276
  setError(null);
277
+ setGateRefusal(null);
235
278
  if (!slug || !eventId) return;
236
279
  if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
237
280
  setError("Please enter your name and a valid email.");
@@ -247,7 +290,17 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
247
290
  firstName,
248
291
  lastName,
249
292
  questionnaire,
293
+ // Omitted entirely when the event does not collect them, so an ordinary
294
+ // purchase posts the body it always did.
295
+ ...(attendeesPayload(attendeeSlots) ? { attendees: attendeesPayload(attendeeSlots) } : {}),
250
296
  couponCode: coupon.submittedCode,
297
+ // The presale code, not a discount. The sell guard re-checks it on the
298
+ // ORDER, so it has to travel with the purchase and not just with the
299
+ // read that revealed the tier — otherwise a buyer unlocks a presale,
300
+ // fills in their details, and is refused at the card step for want of
301
+ // the code they already typed. Omitted when there is none, so an
302
+ // ordinary purchase posts the body it always did.
303
+ ...(presale.code ? { accessCode: presale.code } : {}),
251
304
  attributionRefId: readAttributionRef() ?? undefined,
252
305
  ...(readLanding() ?? {}),
253
306
  });
@@ -279,10 +332,36 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
279
332
  if (result.provider && result.provider !== "stripe") return; // Paystack redirected
280
333
  setStep("payment");
281
334
  } catch (e) {
335
+ /**
336
+ * A members-only tier the buyer cannot have. Checked BEFORE the coupon
337
+ * branch: a buyer who happened to type a discount code would otherwise be
338
+ * told the CODE was refused and sent to fix a code that was never the
339
+ * problem.
340
+ */
341
+ const gate = parseMembershipGateError(e);
342
+ if (gate) {
343
+ setGateRefusal(gate);
344
+ return;
345
+ }
282
346
  // A refused code fails the whole request, so with one entered the message
283
347
  // belongs next to the field the buyer just used. The message is the API's
284
348
  // own — "This coupon has expired", "…does not reach this coupon's minimum
285
349
  // spend" — never a house "invalid code" that hides which it was.
350
+ /**
351
+ * The reservation lapsed mid-payment (409 `INVENTORY_HOLD_EXPIRED`) —
352
+ * NOT sold out, and emphatically not a bad coupon. Checked before the
353
+ * coupon branch for the same reason the gate is: a buyer who happened to
354
+ * type a discount code would otherwise be sent to fix a code that was
355
+ * never the problem, while the one action that works — retry — is not
356
+ * offered at all.
357
+ *
358
+ * Event ticket holds are taken UNCONDITIONALLY (no feature switch), so
359
+ * unlike the product cart this path is live for every profile.
360
+ */
361
+ if (isHoldExpiredError(e)) {
362
+ setError(holdExpiredMessage(e, "Your ticket reservation expired before payment finished. Please try again."));
363
+ return;
364
+ }
286
365
  if (coupon.submittedCode) coupon.fail(e);
287
366
  else setError(errMessage(e));
288
367
  }
@@ -306,9 +385,47 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
306
385
  setStep("details");
307
386
  };
308
387
 
388
+ /**
389
+ * One slot per seat, in cart order, with the buyer pre-filled into the first.
390
+ * Empty when the event does not ask — so a normal event renders and posts
391
+ * exactly what it always did.
392
+ */
393
+ /**
394
+ * The reservation clock. `undefined` on an older API and `null` when no hold
395
+ * was taken or the order is already paid — both render nothing, i.e. exactly
396
+ * the behaviour before holds existed.
397
+ */
398
+ const holdExpiresAt = (flow.result as { holdExpiresAt?: string | null } | undefined)?.holdExpiresAt ?? null;
399
+ const hold = useInventoryHold(holdExpiresAt);
400
+
401
+ const attendeeSlots = useMemo(
402
+ () =>
403
+ collectsAttendees(event)
404
+ ? buildAttendeeSlots({
405
+ tickets: (event?.tickets ?? []).filter((t) => (selectedTickets[t.id] ?? 0) > 0),
406
+ quantities: selectedTickets,
407
+ names: attendeeNames,
408
+ buyerName: buyerFullName({ firstName, lastName }),
409
+ })
410
+ : [],
411
+ [event, selectedTickets, attendeeNames, firstName, lastName],
412
+ );
413
+
414
+ const attendeeCheck = useMemo(() => validateAttendeeNames(attendeeSlots), [attendeeSlots]);
415
+
416
+ const setAttendee = (ticketId: string, index: number, value: string) =>
417
+ setAttendeeNames((prev) => setAttendeeName(prev, ticketId, index, value));
418
+
309
419
  return {
310
420
  event,
311
421
  isLoading,
422
+ /** Countdown on the ticket reservation. `active:false` when there is none. */
423
+ hold,
424
+ /** Per-seat name inputs. Empty array when the event does not collect them. */
425
+ attendeeSlots,
426
+ setAttendee,
427
+ /** `{ok:false}` while a required seat name is missing or too long. */
428
+ attendeeCheck,
312
429
  step,
313
430
  setStep,
314
431
  selectedTickets,
@@ -354,6 +471,12 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
354
471
  /** Discount code state + the server's quote. See `useCouponField`. */
355
472
  coupon,
356
473
  clearCoupon,
474
+ /**
475
+ * Presale-code state (1.2) — a DIFFERENT code from `coupon`: it buys access
476
+ * to a tier, it does not change a price. `presale.visible` is false on every
477
+ * event without a coded tier, so a surface can render it unconditionally.
478
+ */
479
+ presale,
357
480
  /** Authoritative sales-tax quote from start-payment (display only). */
358
481
  taxQuote: flow.result?.taxQuote ?? null,
359
482
  firstName,
@@ -373,5 +496,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
373
496
  returnUrl,
374
497
  isProcessing: createOrder.isPending || flow.isStarting,
375
498
  error,
499
+ /**
500
+ * A `MEMBERSHIP_GATE` refusal, structured — pass it to
501
+ * `<MembershipGateNotice refusal={…}>` (or `useMembershipGateNotice`) rather
502
+ * than printing `error`, which is deliberately NOT set for this case: the
503
+ * buyer needs the tier's name and a way in, and a generic red line gives
504
+ * them neither.
505
+ */
506
+ gateRefusal,
376
507
  };
377
508
  }
@@ -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
+ }