@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.21.0",
3
+ "version": "3.23.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,6 +20,7 @@
20
20
  "test:watch": "vitest"
21
21
  },
22
22
  "dependencies": {
23
+ "@paystack/inline-js": "^2.24.0",
23
24
  "@radix-ui/react-dialog": "^1.1.14",
24
25
  "@radix-ui/react-slot": "^1.2.3",
25
26
  "@stripe/react-stripe-js": "^3.8.0",
@@ -6,6 +6,7 @@ import {
6
6
  isLiveWaitlistEntry,
7
7
  isTicketSoldOut,
8
8
  rememberWaitlistEntry,
9
+ readWaitlistClaimFromUrl,
9
10
  ticketSeatsRemaining,
10
11
  } from "../useEventWaitlist";
11
12
  import type { IEvent, ITicket } from "../../../types/models";
@@ -120,3 +121,40 @@ describe("the anonymous joiner's remembered capability ids", () => {
120
121
  expect(getRememberedWaitlistEntries("e1")).toEqual([]);
121
122
  });
122
123
  });
124
+
125
+ /**
126
+ * Events 2.6b — the offer email's claim link.
127
+ *
128
+ * `?waitlistEntry=<uuid>&waitlistClaim=<64 hex>`. The parse is deliberately
129
+ * strict about the token's shape so a junk query string never becomes a request,
130
+ * and a link missing either half is simply not a claim — the visitor gets the
131
+ * ordinary page rather than a broken one.
132
+ */
133
+ describe("readWaitlistClaimFromUrl", () => {
134
+ const token = "a".repeat(64);
135
+
136
+ it("reads a well-formed claim", () => {
137
+ expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${token}`)).toEqual({
138
+ entryId: "entry-1",
139
+ token,
140
+ });
141
+ });
142
+
143
+ it("survives the other query parameters an email link picks up", () => {
144
+ expect(
145
+ readWaitlistClaimFromUrl(`?utm_source=email&waitlistClaim=${token}&waitlistEntry=entry-9&ref=x`),
146
+ ).toEqual({ entryId: "entry-9", token });
147
+ });
148
+
149
+ it("is null when either half is missing", () => {
150
+ expect(readWaitlistClaimFromUrl(`?waitlistClaim=${token}`)).toBeNull();
151
+ expect(readWaitlistClaimFromUrl("?waitlistEntry=entry-1")).toBeNull();
152
+ expect(readWaitlistClaimFromUrl("")).toBeNull();
153
+ });
154
+
155
+ it("rejects a token that is not exactly 64 lowercase hex characters", () => {
156
+ for (const bad of ["nope", "A".repeat(64), "a".repeat(63), "a".repeat(65), "../../etc"]) {
157
+ expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${bad}`)).toBeNull();
158
+ }
159
+ });
160
+ });
@@ -3,18 +3,19 @@ import {
3
3
  forgetPassTransfer,
4
4
  getRememberedPassTransfers,
5
5
  isPendingPassTransfer,
6
+ mergePendingTransfer,
6
7
  rememberPassTransfer,
7
8
  } from "../usePassTransfers";
8
- import { myTicketPassIds } from "../useMyTickets";
9
+ import { myTicketHeldPassIds, myTicketPassIds, type MyTicket } from "../useMyTickets";
9
10
 
10
11
  /**
11
12
  * Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
12
13
  *
13
14
  * Only the total functions are exercised; the hooks themselves are thin
14
15
  * transport whose behaviour belongs to the API's own specs. The local store is
15
- * the one thing here with real logic, and it exists because there is NO
16
- * buyer-readable list endpoint the transfer history is operator-only so a
17
- * bug in it is a pending transfer the sender can never withdraw.
16
+ * the one thing here with real logic; it used to be the ONLY record a buyer had
17
+ * of their own send, and is now demoted to a bridge across the refetch that
18
+ * follows one, with `my-tickets`' `pendingTransfer` as the source of truth.
18
19
  */
19
20
 
20
21
  const clear = () => {
@@ -87,3 +88,98 @@ describe("the ids a transfer can be addressed by", () => {
87
88
  expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
88
89
  });
89
90
  });
91
+
92
+
93
+ /**
94
+ * One live transfer per pass is a DATABASE guarantee (a partial unique index),
95
+ * so exactly one row may ever be drawn. Two would be a lie about the ticket's
96
+ * state and would offer two "withdraw" buttons for one thing.
97
+ */
98
+ describe("mergePendingTransfer", () => {
99
+ const remembered = {
100
+ passId: "TN-1",
101
+ transferId: "local-1",
102
+ toEmail: "local@example.com",
103
+ expiresAt: "2026-04-01T00:00:00.000Z",
104
+ };
105
+
106
+ const fromServer = {
107
+ id: "server-1",
108
+ status: "pending",
109
+ toName: null,
110
+ toEmail: "server@example.com",
111
+ expiresAt: "2026-04-02T00:00:00.000Z",
112
+ createdAt: "2026-03-30T00:00:00.000Z",
113
+ window: { open: true, secondsRemaining: 3600, expiresAt: "2026-04-02T00:00:00.000Z" },
114
+ };
115
+
116
+ it("is null when nothing is in flight anywhere", () => {
117
+ expect(mergePendingTransfer(null, [])).toBeNull();
118
+ });
119
+
120
+ it("THE POINT: the server's row is what a second device sees", () => {
121
+ // `localStorage` is empty here — this browser never sent anything. Before
122
+ // the API carried `pendingTransfer` this returned nothing, which is why a
123
+ // sender on another device had no transfer to withdraw.
124
+ const merged = mergePendingTransfer(fromServer, [])!;
125
+
126
+ expect(merged.transferId).toBe("server-1");
127
+ expect(merged.toEmail).toBe("server@example.com");
128
+ expect(merged.fromServer).toBe(true);
129
+ expect(merged.open).toBe(true);
130
+ });
131
+
132
+ it("the server WINS over a stale local record, rather than drawing both", () => {
133
+ const merged = mergePendingTransfer(fromServer, [remembered])!;
134
+ expect(merged.transferId).toBe("server-1");
135
+ expect(merged.fromServer).toBe(true);
136
+ });
137
+
138
+ it("falls back to the local bridge before the refetch lands", () => {
139
+ const merged = mergePendingTransfer(null, [remembered])!;
140
+ expect(merged.transferId).toBe("local-1");
141
+ expect(merged.fromServer).toBe(false);
142
+ // Optimistic: a just-sent transfer is live by construction, and the server
143
+ // corrects it on the very next read.
144
+ expect(merged.open).toBe(true);
145
+ });
146
+
147
+ it("reports a lapsed-but-unswept transfer as closed, and still withdrawable", () => {
148
+ const lapsed = { ...fromServer, window: { open: false, secondsRemaining: 0, expiresAt: fromServer.expiresAt } };
149
+ const merged = mergePendingTransfer(lapsed, [])!;
150
+
151
+ // Hiding it would put the sender back where they started: a live row they
152
+ // cannot address. The row stays, the copy changes.
153
+ expect(merged.open).toBe(false);
154
+ expect(merged.transferId).toBe("server-1");
155
+ });
156
+ });
157
+
158
+ /**
159
+ * Which passes may an affordance be drawn on?
160
+ *
161
+ * Every pass-scoped endpoint — transfer, wallet, QR — resolves the current
162
+ * HOLDER, and a purchaser sees passes they do not hold: named guests at
163
+ * checkout, and (until the read was fixed) tickets already claimed by somebody
164
+ * else. A control on one of those could only ever answer 404.
165
+ */
166
+ describe("myTicketHeldPassIds", () => {
167
+ const ticket = (passes: { id: string; isHolder?: boolean }[]) =>
168
+ ({ items: [{ id: "item-1", quantity: passes.length, price: 10, ticketTitle: "GA", passes }] }) as unknown as MyTicket;
169
+
170
+ it("keeps only the passes the caller holds", () => {
171
+ const t = ticket([
172
+ { id: "TN-1", isHolder: true },
173
+ { id: "TN-2", isHolder: false },
174
+ ]);
175
+
176
+ expect(myTicketHeldPassIds(t)).toEqual(["TN-1"]);
177
+ // The unfiltered helper still reports everything on the order — the wallet
178
+ // and transfer controls are the things that need narrowing, not the list.
179
+ expect(myTicketPassIds(t)).toEqual(["TN-1", "TN-2"]);
180
+ });
181
+
182
+ it("treats an ABSENT flag as held, so an older API build is unchanged", () => {
183
+ expect(myTicketHeldPassIds(ticket([{ id: "TN-9" }]))).toEqual(["TN-9"]);
184
+ });
185
+ });
@@ -39,7 +39,7 @@ export function useForgotPassword() {
39
39
  if (!contextId) {
40
40
  // Better than posting without it: the endpoint would 400 on schema
41
41
  // validation and the form would show "something went wrong".
42
- throw new Error("useForgotPassword: no profileId pass one or render inside a ForgeProvider with profileId");
42
+ throw new Error("useForgotPassword: no profileId. Pass one or render inside a ForgeProvider with profileId");
43
43
  }
44
44
  const res = await client.post("/public/sessions/forgot-password", {
45
45
  profileId: contextId,
@@ -143,7 +143,12 @@ export type ApplyCheckoutCouponResult = {
143
143
  /** NET = max(0, subtotalCents − discountCents), minor units. */
144
144
  totalCents: number;
145
145
  appliedCoupons: AppliedBundleCoupon[];
146
+ /** STRIPE: the re-issued PaymentIntent client secret. */
146
147
  paymentSecret: string;
148
+ /** PAYSTACK: the re-issued inline-checkout session. */
149
+ accessCode?: string;
150
+ /** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
151
+ checkoutUrl?: string;
147
152
  paymentId: string;
148
153
  chargedAmount: number;
149
154
  chargedCurrency: string;
@@ -17,6 +17,10 @@ import type { IEvent, ITicket } from "../../types/models";
17
17
  * POST /public/events/:eventId/waitlist join → WaitlistEntry (201)
18
18
  * GET /public/events/:eventId/waitlist mine → WaitlistEntry[] (401 if anonymous)
19
19
  * GET /public/events/:eventId/waitlist/:entryId one → WaitlistEntry
20
+ * …optionally `&claimToken=<64 hex>` — the offer email's credential (2.6b),
21
+ * which is the ONLY way an offered fan can read their place on a device
22
+ * that never joined and is not signed in. That is the device the email is
23
+ * opened on.
20
24
  * DELETE /public/events/:eventId/waitlist/:entryId leave → { id, status, leftAt }
21
25
  * ```
22
26
  *
@@ -49,6 +53,22 @@ export type WaitlistOffer = {
49
53
  secondsRemaining: number | null;
50
54
  /** The instant the offer lapses — the single most important field here. */
51
55
  claimExpiresAt: string | null;
56
+ /**
57
+ * Are the offered seats actually HELD for this person until the deadline
58
+ * (events 2.6b)?
59
+ *
60
+ * Distinct from `offerOpen`, and the distinction is the whole change. An offer
61
+ * used to reserve nothing — the fan raced the open page, and the copy said so.
62
+ * A reserved offer takes a real inventory hold for the length of the window,
63
+ * so the tier reads sold out to everybody else. Render "these are yours until
64
+ * Friday" ONLY when this is true; an unreserved offer (one made before the
65
+ * reservation existed, or one whose hold a failed payment released) is still a
66
+ * real offer with a real deadline and must keep the older, weaker promise.
67
+ *
68
+ * Optional so a site pinned to an older API build degrades to "not reserved"
69
+ * rather than claiming a hold that is not there.
70
+ */
71
+ seatsReserved?: boolean;
52
72
  };
53
73
 
54
74
  export type WaitlistEntry = {
@@ -88,14 +108,26 @@ export const isLiveWaitlistEntry = (entry: Pick<WaitlistEntry, "status">): boole
88
108
  // ── Where a join control may be DRAWN ────────────────────────────────────────
89
109
 
90
110
  /**
91
- * Seats a buyer can still take, by the same arithmetic the purchase UI uses.
111
+ * Seats that are not SOLD — the waitlist's question, which is not the purchase
112
+ * UI's question.
92
113
  *
93
114
  * Deliberately ignores `held` (units inside somebody else's live checkout) even
94
115
  * though the server's waitlist rule subtracts it. The difference only ever makes
95
116
  * this stricter: `quantity - sold <= 0` implies `quantity - sold - held <= 0`,
96
117
  * so a tier this calls sold out is always one the server will accept a join for.
97
- * Including `held` would do the opposite — offer a waitlist beside a "+" button
98
- * the buyer can still press, on a tier that unfreezes minutes later.
118
+ *
119
+ * The purchase UI DOES subtract `held` it has to, because that is what the
120
+ * server sells on (`ticketSeatsAvailable` in `ui/format/ticketAvailability`).
121
+ * The two are answering different things and the gap between them is deliberate:
122
+ *
123
+ * - a tier with held-but-unsold seats is un-addable and says "ON HOLD", and
124
+ * offers no waitlist, because the seats are coming back;
125
+ * - a tier with nothing unsold says "SOLD OUT" and offers the queue.
126
+ *
127
+ * Collapsing them would put a "join the waitlist" beside a tier that unfreezes
128
+ * five minutes later, and send a fan an email about a seat they could have had
129
+ * by waiting. Identical to `waitlistAvailableSeats` on the server only in shape,
130
+ * not in intent — that one gates OFFERS, this one gates where a join is drawn.
99
131
  */
100
132
  export const ticketSeatsRemaining = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
101
133
  Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
@@ -334,6 +366,16 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
334
366
  const { client, profileId } = useForge();
335
367
  const mine = useMyEventWaitlist(eventId, accountId);
336
368
  const remembered = useRememberedWaitlistEntries(eventId);
369
+ /**
370
+ * 2.6b — a THIRD source: the offer the visitor arrived holding.
371
+ *
372
+ * The other two both assume continuity with the browser that joined — a
373
+ * session, or a remembered id in this localStorage. An offer email is read on
374
+ * a phone, and neither holds there, so the fan landed on an ordinary sold-out
375
+ * page with a "join the waitlist" form beside the seats already reserved for
376
+ * them. The token in the link is what makes that place readable.
377
+ */
378
+ const claim = useEventWaitlistClaim(eventId);
337
379
 
338
380
  const entryQueries = useQueries({
339
381
  queries: remembered.map((item) => ({
@@ -359,15 +401,21 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
359
401
  const entry = query.data;
360
402
  if (entry && !byId.has(entry.id)) byId.set(entry.id, entry);
361
403
  }
404
+ // Last, and unconditionally: an entry the link addresses is the one the
405
+ // visitor is here about. `set` rather than a has-check so the token-scoped
406
+ // read wins if the same row arrived by another route.
407
+ if (claim.entry) byId.set(claim.entry.id, claim.entry);
362
408
  return [...byId.values()];
363
409
  // eslint-disable-next-line react-hooks/exhaustive-deps
364
- }, [mine.data, entryStamp]);
410
+ }, [mine.data, entryStamp, claim.entry]);
365
411
 
366
412
  return {
367
413
  entries,
368
414
  /** Only the LIVE ones — what "am I on the waitlist?" means to a buyer. */
369
415
  liveEntries: useMemo(() => entries.filter(isLiveWaitlistEntry), [entries]),
370
- isLoading: mine.isLoading || entryQueries.some((query) => query.isLoading),
416
+ /** The claim this visitor arrived with, for the surface that spends it. */
417
+ claim,
418
+ isLoading: mine.isLoading || claim.isLoading || entryQueries.some((query) => query.isLoading),
371
419
  };
372
420
  }
373
421
 
@@ -427,3 +475,73 @@ export function useMyWaitlistPlaces(accountId?: string) {
427
475
  isLoading: eventsLoading || listQueries.some((query) => query.isLoading),
428
476
  };
429
477
  }
478
+
479
+ // ── The offer email's claim link (2.6b) ──────────────────────────────────────
480
+
481
+ /**
482
+ * The claim credential an offer email hands out.
483
+ *
484
+ * The link used to be the ordinary public event page, which proved nothing about
485
+ * who was holding it — and once the offer started RESERVING seats it could not
486
+ * even be used, because the reservation makes the tier read sold out to
487
+ * everybody, the offeree included. The email now carries
488
+ * `?waitlistEntry=<id>&waitlistClaim=<64 hex>`: the id addresses the place, the
489
+ * token proves the holder and is what lets the reserved hold be spent.
490
+ */
491
+ export type WaitlistClaimLink = { entryId: string; token: string };
492
+
493
+ /** 64 lowercase hex, matching the server's own shape gate. */
494
+ const isWellFormedClaimToken = (value: string | null): value is string =>
495
+ !!value && /^[0-9a-f]{64}$/.test(value);
496
+
497
+ /**
498
+ * Read the claim out of the CURRENT url.
499
+ *
500
+ * Deliberately not stored anywhere. It is a bearer credential with a deadline,
501
+ * and the durable copy is the email; putting it in localStorage would leave a
502
+ * spendable token on a shared device long after the offer died, for no benefit
503
+ * — the fan re-opens the email.
504
+ */
505
+ export const readWaitlistClaimFromUrl = (search?: string): WaitlistClaimLink | null => {
506
+ const raw = search ?? (typeof window === "undefined" ? "" : window.location.search);
507
+ if (!raw) return null;
508
+ const params = new URLSearchParams(raw);
509
+ const entryId = params.get("waitlistEntry");
510
+ const token = params.get("waitlistClaim");
511
+ if (!entryId || !isWellFormedClaimToken(token)) return null;
512
+ return { entryId, token };
513
+ };
514
+
515
+ /**
516
+ * The offer this visitor arrived holding, if they arrived holding one.
517
+ *
518
+ * `enabled` on the token means an ordinary visitor makes no request at all. A
519
+ * dead or foreign token 404s, which is an ANSWER — the offer is over, or the
520
+ * link belongs to another artist — so it is not retried and the caller simply
521
+ * renders the ordinary page.
522
+ */
523
+ export function useEventWaitlistClaim(eventId?: string, search?: string) {
524
+ const { client, profileId } = useForge();
525
+ const link = useMemo(() => readWaitlistClaimFromUrl(search), [search]);
526
+
527
+ const query = useQuery<WaitlistEntry>({
528
+ queryKey: [...waitlistKey(eventId), "claim", link?.entryId, profileId],
529
+ queryFn: async () => {
530
+ const res = await client.get(`/public/events/${eventId}/waitlist/${link!.entryId}`, {
531
+ params: { profileId, claimToken: link!.token },
532
+ });
533
+ return res.data;
534
+ },
535
+ enabled: !!eventId && !!profileId && !!client && !!link,
536
+ retry: false,
537
+ });
538
+
539
+ return {
540
+ /** The raw token, to be posted with the order that spends the reservation. */
541
+ token: link?.token ?? null,
542
+ entry: query.data ?? null,
543
+ /** True only while the seats are genuinely held and the window is open. */
544
+ isClaimable: !!query.data?.offer?.offerOpen && !!query.data?.offer?.seatsReserved,
545
+ isLoading: query.isLoading,
546
+ };
547
+ }
@@ -25,19 +25,48 @@ export function useEvents() {
25
25
  * seed the query — the hook then returns data on first render with no loading
26
26
  * state, so the page can be server-rendered for SEO with no client spinner.
27
27
  */
28
- export function useEvent(id?: string, options?: { initialData?: IEvent }) {
28
+ export function useEvent(
29
+ id?: string,
30
+ options?: {
31
+ initialData?: IEvent;
32
+ /**
33
+ * A presale code the buyer holds (1.2). A correct one reveals the hidden
34
+ * tiers it unlocks, in this same response, and sets `presale.codeAccepted`.
35
+ *
36
+ * Part of the query KEY, so entering a code refetches rather than serving
37
+ * the unrevealed cache — and so the seeded `initialData` (fetched without a
38
+ * code) is not mistaken for a coded response.
39
+ */
40
+ accessCode?: string | null;
41
+ },
42
+ ) {
29
43
  const { client, profileId } = useForge();
44
+ const accessCode = options?.accessCode || undefined;
45
+ // Only callers that pass the option at all — presale-aware ones — get the
46
+ // hold-the-last-page behaviour below. Everyone else keeps the old semantics
47
+ // exactly, including "the event goes undefined while a DIFFERENT event
48
+ // loads", which is what a router navigation wants.
49
+ const presaleAware = !!options && "accessCode" in options;
30
50
 
31
51
  return useQuery<IEvent>({
32
- queryKey: ["event", id, profileId],
52
+ queryKey: ["event", id, profileId, accessCode ?? null],
33
53
  queryFn: async () => {
34
54
  const res = await client.get(`/public/events/${id}`, {
35
- params: { profileId },
55
+ params: { profileId, ...(accessCode ? { accessCode } : {}) },
36
56
  });
37
57
  return res.data;
38
58
  },
39
59
  enabled: !!id && !!profileId && !!client,
40
- initialData: options?.initialData,
60
+ // Seeding only applies to the uncoded query: `initialData` came from a
61
+ // loader that had no code, so handing it to a coded key would show an
62
+ // unrevealed tier list and claim the code was applied.
63
+ initialData: accessCode ? undefined : options?.initialData,
64
+ // Applying a code changes the query key, which would otherwise blank the
65
+ // whole event page back to a spinner while the coded read is in flight —
66
+ // the buyer presses Apply and the show they were reading disappears. Keep
67
+ // the last event on screen and let `isFetching` drive the field's own
68
+ // "Checking…".
69
+ placeholderData: presaleAware ? (previous?: IEvent) => previous : undefined,
41
70
  });
42
71
  }
43
72
 
@@ -49,6 +78,21 @@ export type CreateEventOrderInput = {
49
78
  * the tier's own `price` — it can raise what is charged, never lower it.
50
79
  */
51
80
  amounts?: Record<string, number>;
81
+ /**
82
+ * Who is actually coming, keyed by ticket id exactly as `items` is, one entry
83
+ * per seat of that tier in issue order (Events 2.1).
84
+ *
85
+ * Send it only when the event has `collectAttendeeDetails` on — the server
86
+ * does not read it otherwise. A list whose length does not equal that tier's
87
+ * quantity is REFUSED (a short list would leave a silent extra pass in the
88
+ * purchaser's name), so build it with `attendeesPayload` from
89
+ * `forge/ui`'s attendee helpers rather than by hand.
90
+ *
91
+ * `email` is accepted per attendee and deliberately not sent by either
92
+ * storefront: it is the pass's OWNER, so naming another address hands that
93
+ * seat away. Handing a ticket to somebody else is what transfer is for.
94
+ */
95
+ attendees?: Record<string, { name: string; email?: string }[]>;
52
96
  email: string;
53
97
  firstName?: string;
54
98
  lastName?: string;
@@ -60,6 +104,32 @@ export type CreateEventOrderInput = {
60
104
  * the reason as its message and no order is created.
61
105
  */
62
106
  couponCode?: string;
107
+ /**
108
+ * The PRESALE code (1.2) — not the discount code above. It buys access to a
109
+ * tier, it does not change a price.
110
+ *
111
+ * The sell guard runs against the tier's own column on every order, so this
112
+ * has to travel with the purchase and not merely with the read that revealed
113
+ * the tier: a buyer who unlocks a presale and then checks out without it gets
114
+ * their order refused after entering their card details.
115
+ */
116
+ accessCode?: string;
117
+ /**
118
+ * The waitlist offer's claim token (events 2.6b) — 64 hex from the offer
119
+ * email's link, NOT a discount and NOT a presale code.
120
+ *
121
+ * It is a capability over inventory that is ALREADY reserved: the offer took
122
+ * a real hold when it was made, so presenting this makes the order spend that
123
+ * hold rather than acquire a new one. Without it an offeree cannot buy at
124
+ * all, because their own reservation is what makes the tier read sold out to
125
+ * them.
126
+ *
127
+ * The server refuses a claim it cannot honour (closed window, released
128
+ * reservation, a cart reaching past the offered tier) rather than quietly
129
+ * falling back to an ordinary purchase — so a refusal here is the truth about
130
+ * the offer, not a generic sold-out.
131
+ */
132
+ waitlistClaimToken?: string;
63
133
  };
64
134
 
65
135
  export type CreateEventOrderResult = {
@@ -83,6 +153,14 @@ export type CreateEventOrderResult = {
83
153
  couponId?: string | null;
84
154
  /** Every discount that applied, entered OR automatic. */
85
155
  appliedCoupons?: AppliedDiscountCoupon[];
156
+ /**
157
+ * When these tickets stop being reserved, ISO-8601.
158
+ *
159
+ * `null` for a free order — its hold has already converted by the time this
160
+ * returns, so a countdown on it would be counting something that no longer
161
+ * exists — and for a profile with inventory holds switched off.
162
+ */
163
+ holdExpiresAt?: string | null;
86
164
  };
87
165
 
88
166
  /** Create an event ticket order (the step before start-payment). */
@@ -2,7 +2,13 @@ import type { MembershipTier } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
- export function useGetMembershipTiers() {
5
+ /**
6
+ * `opts.enabled` gates the request on top of the usual profile/client checks —
7
+ * for a caller that only needs tier NAMES when something is actually gated, and
8
+ * must not add a request to every ungated storefront. Defaults to on, so every
9
+ * existing caller is unchanged.
10
+ */
11
+ export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
6
12
  const { client, profileId } = useForge();
7
13
 
8
14
  return useQuery<MembershipTier[]>({
@@ -15,6 +21,6 @@ export function useGetMembershipTiers() {
15
21
  });
16
22
  return res.data;
17
23
  },
18
- enabled: !!profileId && !!client,
24
+ enabled: !!profileId && !!client && opts.enabled !== false,
19
25
  });
20
26
  }
@@ -108,11 +108,48 @@ export type MyBooking = {
108
108
  coachingProductTimezone: string | null;
109
109
  coachingProductDurationMinutes: number | null;
110
110
  cancellation: BookingCancellation;
111
+ /** Already resolved server-side — see {@link BookingLocation}. */
112
+ location?: BookingLocation | null;
111
113
  /** The server's answer. Never recomputed here. */
112
114
  canReschedule: boolean;
113
115
  rescheduleReason: RescheduleRefusal | null;
116
+ /**
117
+ * The session's `.ics`, both forms — what lets the portal offer Apple
118
+ * Calendar rather than only Google/Outlook/Microsoft 365. `null` unless the
119
+ * booking is confirmed.
120
+ *
121
+ * The address is stable across a RESCHEDULE (it is HMAC(kind:id) and the
122
+ * booking id does not change), which is the mechanism by which a re-fetch
123
+ * moves the entry already in the buyer's calendar instead of adding a second
124
+ * one. Optional: an older API build does not emit it.
125
+ */
126
+ calendarUrl?: string | null;
127
+ calendarWebcalUrl?: string | null;
114
128
  };
115
129
 
130
+ /**
131
+ * WHERE the session happens.
132
+ *
133
+ * The server has already resolved this — the booking's own override if the
134
+ * coach set one for this session, otherwise the coaching product's default —
135
+ * so there is exactly one value and no rule to re-derive in the browser. Two
136
+ * fields would eventually be rendered in the wrong order by one of the two
137
+ * rendering stacks, and the consequence of that is not cosmetic: it is a client
138
+ * dialling into an empty room.
139
+ *
140
+ * `null` means the coach has not said where the session is. Draw nothing; the
141
+ * confirmation email carries the same gap and inventing a placeholder ("Online")
142
+ * would promise a link that does not exist.
143
+ *
144
+ * Withheld for anything not `confirmed`: a cancelled session has no venue to go
145
+ * to and no call to join.
146
+ *
147
+ * Optional on the type because an older API build does not emit it.
148
+ */
149
+ export type BookingLocation =
150
+ | { type: "in_person"; address: string }
151
+ | { type: "online"; url: string };
152
+
116
153
  export type CancelBookingResult = {
117
154
  bookingId: string;
118
155
  /** True when money actually went back; false for a free or already-refunded session. */