@tribe-nest/forge 3.54.0 → 3.58.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 (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -1,10 +1,12 @@
1
- import { useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
3
  import { useCart } from "../../../contexts/CartContext";
4
4
  import { useCreateEventOrder } from "../../../data/queries/useEvents";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
6
  import { useCouponField } from "../coupon/useCouponField";
7
7
  import { usePresaleCode } from "./usePresaleCode";
8
+ import { bestAvailableSeats, useEventSeating, type EventSeat } from "./useEventSeating";
9
+ import { maxAddableTickets } from "../../format/ticketAvailability";
8
10
  import { useEventWaitlistClaim } from "../../../data/queries/useEventWaitlist";
9
11
  import {
10
12
  buildAttendeeSlots,
@@ -16,6 +18,7 @@ import {
16
18
  type AttendeeNames,
17
19
  } from "../../format/attendees";
18
20
  import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
21
+ import { cartHasSeatedLines } from "../checkout/seatConflict";
19
22
  import { useInventoryHold } from "../checkout/useInventoryHold";
20
23
  import { computeBookingFeeAmount } from "../../format/bookingFee";
21
24
  import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
@@ -43,8 +46,29 @@ export interface UseEventCheckoutOptions {
43
46
  * the finalise page it lands on reads the order's status and renders
44
47
  * "request received". */
45
48
  onComplete?: (info: { slug: string; orderId: string }) => void;
49
+ /**
50
+ * Whether the seat picker is on screen (seating wave 2). The seating read
51
+ * polls every five seconds while this is true and is not requested at all
52
+ * while it is false, so a surface that mounts the hook for a closed modal
53
+ * passes its open state here. Default true. Irrelevant on an event with no
54
+ * seat map, which never requests the read.
55
+ */
56
+ seatingActive?: boolean;
46
57
  }
47
58
 
59
+ /** The result of a seat tap, so a surface can say WHY a tap did nothing. */
60
+ export type ToggleSeatResult =
61
+ | { ok: true; selected: boolean }
62
+ | { ok: false; reason: "unavailable" | "max" | "mix" | "unknown" };
63
+
64
+ /** A 409 `seats_taken`: the seats another buyer took between the read and the order. */
65
+ const seatsTakenIds = (e: unknown): string[] | null => {
66
+ const res = (e as { response?: { status?: number; data?: { details?: { eventSeatIds?: unknown } } } })?.response;
67
+ const ids = res?.data?.details?.eventSeatIds;
68
+ if (res?.status !== 409 || !Array.isArray(ids)) return null;
69
+ return ids.filter((id): id is string => typeof id === "string");
70
+ };
71
+
48
72
  const errMessage = (e: unknown) =>
49
73
  (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
50
74
 
@@ -56,7 +80,7 @@ const errMessage = (e: unknown) =>
56
80
  */
57
81
  export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions = {}) {
58
82
  const { user } = usePublicAuth();
59
- const { setTickets, hasTicketsFor } = useCart();
83
+ const { setTickets, hasTicketsFor, ticketItems } = useCart();
60
84
  // The detail is resolved by slug; the order is created against the real event
61
85
  // id (the orders endpoint looks the event up by id, not slug).
62
86
  // Read THROUGH the presale field rather than calling `useEvent` directly: it
@@ -96,7 +120,63 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
96
120
  const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
97
121
 
98
122
  const [step, setStep] = useState<EventCheckoutStep>("tickets");
99
- const [selectedTickets, setSelectedTickets] = useState<Record<string, number>>({});
123
+ /**
124
+ * Quantities the buyer chose with the stepper, for GENERAL ADMISSION tiers.
125
+ *
126
+ * Not the whole cart: on a reserved-seating tier the quantity is not a number
127
+ * the buyer types, it is the size of the seat selection below, and
128
+ * `selectedTickets` derives it from there. Keeping a second count for a seated
129
+ * tier would be the one way the two could disagree.
130
+ */
131
+ const [gaQuantities, setGaQuantities] = useState<Record<string, number>>({});
132
+ /**
133
+ * ticketId -> the EVENT seat ids the buyer picked (seating wave 2), in the
134
+ * order they were picked, so "-" drops the most recent one.
135
+ */
136
+ const [selectedSeats, setSelectedSeats] = useState<Record<string, string[]>>({});
137
+ /**
138
+ * Seats the buyer had chosen that are no longer theirs to choose: taken by
139
+ * another buyer between the read and the order (a 409), or read
140
+ * `unavailable` by a poll while still selected. Named, because "some seats
141
+ * changed" sends the buyer back to the map to work out which.
142
+ */
143
+ const [seatsTaken, setSeatsTaken] = useState<{ labels: string[] } | null>(null);
144
+ /**
145
+ * Seats THIS buyer's own order holds. After `createOrder` succeeds the hold
146
+ * makes them read `unavailable` to everyone, this buyer included; without
147
+ * this a fan pressing Back would watch their own seats get "taken".
148
+ */
149
+ const ownHeldSeatIds = useRef<Set<string>>(new Set());
150
+
151
+ /**
152
+ * The seat map and live availability (seating wave 2). Requested only on an
153
+ * event that has a map and only while the picker is on screen; polled while
154
+ * the buyer is on the tickets step, where a seat somebody else takes has to
155
+ * show within seconds, and left alone on the later steps, where the hold is
156
+ * what protects the selection.
157
+ */
158
+ const seating = useEventSeating(eventId, {
159
+ enabled: !!event?.hasSeatMap && (opts.seatingActive ?? true),
160
+ pollMs: step === "tickets" ? 5000 : false,
161
+ });
162
+ const isSeatedTier = (ticketId: string) => seating.seatedTierIds.has(ticketId);
163
+
164
+ /**
165
+ * The cart. GA tiers from the stepper, seated tiers from the seat selection.
166
+ * Only positive quantities: a ticket dropped to 0 is removed entirely, so
167
+ * `items` never carries a 0-quantity entry (the orders endpoint rejects those
168
+ * with "quantity must be positive").
169
+ */
170
+ const selectedTickets = useMemo<Record<string, number>>(() => {
171
+ const out: Record<string, number> = {};
172
+ for (const [ticketId, qty] of Object.entries(gaQuantities)) {
173
+ if (qty > 0 && !seating.seatedTierIds.has(ticketId)) out[ticketId] = qty;
174
+ }
175
+ for (const [ticketId, ids] of Object.entries(selectedSeats)) {
176
+ if (ids.length > 0) out[ticketId] = ids.length;
177
+ }
178
+ return out;
179
+ }, [gaQuantities, selectedSeats, seating.seatedTierIds]);
100
180
  /**
101
181
  * ticketId → the amount the buyer chose, per unit, on a pay-what-you-want
102
182
  * tier. Absent means "hasn't touched the box", which resolves to the tier's
@@ -183,9 +263,25 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
183
263
 
184
264
  const totalAmount = subtotals.paid;
185
265
 
186
- const ticketCount = useMemo(
187
- () => Object.values(selectedTickets).reduce((a, b) => a + b, 0),
188
- [selectedTickets],
266
+ const ticketCount = useMemo(() => Object.values(selectedTickets).reduce((a, b) => a + b, 0), [selectedTickets]);
267
+
268
+ /** At least one reserved seat is in the selection. */
269
+ const hasSeatedSelection = useMemo(() => Object.values(selectedSeats).some((ids) => ids.length > 0), [selectedSeats]);
270
+
271
+ /**
272
+ * What the buyer chose, per seated tier, with the labels a surface prints:
273
+ * the chips under the tier row and the lines in the order summary.
274
+ */
275
+ const seatSummary = useMemo(
276
+ () =>
277
+ (event?.tickets ?? [])
278
+ .filter((t) => (selectedSeats[t.id]?.length ?? 0) > 0)
279
+ .map((t) => ({
280
+ ticketId: t.id,
281
+ title: t.title,
282
+ seats: (selectedSeats[t.id] ?? []).map((id) => ({ id, label: seating.byId.get(id)?.label ?? id })),
283
+ })),
284
+ [event, selectedSeats, seating.byId],
189
285
  );
190
286
 
191
287
  /**
@@ -234,26 +330,202 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
234
330
  // it that way, so a fan choosing to pay ten times the minimum must not see
235
331
  // (or be quoted) ten times the fee. On a cart with no PWYW tier the two
236
332
  // subtotals are the same number and this is byte-for-byte the old behaviour.
237
- const bookingFeeAmount =
238
- serverFeeAmount ?? computeBookingFeeAmount({ subtotal: subtotals.floor, fee: bookingFee });
239
-
240
- // Keep only positive quantities in state — a ticket dropped to 0 is removed
241
- // entirely, so `items` never carries a 0-quantity entry (the orders endpoint
242
- // rejects those with "quantity must be positive").
243
- const setTicketQty = (ticketId: string, qty: number) =>
244
- setSelectedTickets((prev) => {
245
- // A request and a purchase cannot share an order. The picker disables
246
- // the control, and this refuses the write too, so a host that drives the
247
- // hook from its own UI cannot build the cart the server will refuse.
248
- if (qty > 0 && (prev[ticketId] ?? 0) === 0 && event) {
249
- const ticket = event.tickets.find((t) => t.id === ticketId);
250
- if (ticket && tierBlockedByApprovalMix(ticket, event.tickets, prev)) return prev;
333
+ const bookingFeeAmount = serverFeeAmount ?? computeBookingFeeAmount({ subtotal: subtotals.floor, fee: bookingFee });
334
+
335
+ /**
336
+ * A request and a purchase cannot share an order. The picker disables the
337
+ * control, and every write here refuses too, so a host that drives the hook
338
+ * from its own UI cannot build the cart the server will refuse.
339
+ */
340
+ const mixBlocked = (ticketId: string) => {
341
+ if (!event || (selectedTickets[ticketId] ?? 0) > 0) return false;
342
+ const ticket = event.tickets.find((t) => t.id === ticketId);
343
+ return !!ticket && tierBlockedByApprovalMix(ticket, event.tickets, selectedTickets);
344
+ };
345
+
346
+ /**
347
+ * The most seats a buyer may hold on one tier, right now: the same
348
+ * `maxPerPerson` / remaining-seats arithmetic the GA stepper is clamped by,
349
+ * so a seat map cannot be used to walk past a per-person cap.
350
+ */
351
+ const seatCap = (ticketId: string) => {
352
+ const ticket = event?.tickets.find((t) => t.id === ticketId);
353
+ if (!ticket) return 0;
354
+ return maxAddableTickets(ticket, new Date(), reservedForYou[ticketId] ?? 0);
355
+ };
356
+
357
+ const selectedSeatEntries = (ticketId: string): EventSeat[] =>
358
+ (selectedSeats[ticketId] ?? []).map((id) => seating.byId.get(id)).filter((s): s is EventSeat => !!s);
359
+
360
+ /** Add the best `count` available seats to a tier's selection. Returns how many were added. */
361
+ const addBestAvailable = (ticketId: string, count: number): number => {
362
+ const current = selectedSeats[ticketId] ?? [];
363
+ const room = Math.max(0, seatCap(ticketId) - current.length);
364
+ const wanted = Math.min(count, room);
365
+ if (wanted <= 0) return 0;
366
+ const picked = bestAvailableSeats(seating.seats, ticketId, wanted, new Set(current), selectedSeatEntries(ticketId));
367
+ if (picked.length === 0) return 0;
368
+ setSelectedSeats((prev) => ({ ...prev, [ticketId]: [...(prev[ticketId] ?? []), ...picked.map((s) => s.id)] }));
369
+ return picked.length;
370
+ };
371
+
372
+ /**
373
+ * Set a tier's quantity.
374
+ *
375
+ * On a GA tier this writes the number. On a SEATED tier the number is the
376
+ * selection size, so going up picks that many best-available seats and going
377
+ * down drops the most recently picked ones: the +/- stepper keeps working on
378
+ * a seated tier for a buyer who does not care where they sit.
379
+ */
380
+ const setTicketQty = (ticketId: string, qty: number) => {
381
+ if (qty > 0 && mixBlocked(ticketId)) return;
382
+ if (isSeatedTier(ticketId)) {
383
+ const current = selectedSeats[ticketId] ?? [];
384
+ const target = Math.max(0, Math.min(qty, seatCap(ticketId)));
385
+ if (target > current.length) {
386
+ addBestAvailable(ticketId, target - current.length);
387
+ } else if (target < current.length) {
388
+ setSelectedSeats((prev) => {
389
+ const next = { ...prev };
390
+ const kept = (prev[ticketId] ?? []).slice(0, target);
391
+ if (kept.length > 0) next[ticketId] = kept;
392
+ else delete next[ticketId];
393
+ return next;
394
+ });
251
395
  }
396
+ return;
397
+ }
398
+ setGaQuantities((prev) => {
252
399
  const next = { ...prev };
253
400
  if (qty > 0) next[ticketId] = qty;
254
401
  else delete next[ticketId];
255
402
  return next;
256
403
  });
404
+ };
405
+
406
+ /**
407
+ * Select or deselect ONE seat (a tap on the map).
408
+ *
409
+ * Refuses, and says why, rather than silently doing nothing: a tap that
410
+ * fails because the tier is at its per-person cap and a tap that fails
411
+ * because the seat just went are different sentences on screen.
412
+ */
413
+ const toggleSeat = (ticketId: string, seatId: string): ToggleSeatResult => {
414
+ const current = selectedSeats[ticketId] ?? [];
415
+ if (current.includes(seatId)) {
416
+ setSelectedSeats((prev) => {
417
+ const next = { ...prev };
418
+ const kept = (prev[ticketId] ?? []).filter((id) => id !== seatId);
419
+ if (kept.length > 0) next[ticketId] = kept;
420
+ else delete next[ticketId];
421
+ return next;
422
+ });
423
+ return { ok: true, selected: false };
424
+ }
425
+ const seat = seating.byId.get(seatId);
426
+ if (!seat || seat.eventTicketId !== ticketId) return { ok: false, reason: "unknown" };
427
+ if (seat.status !== "available") return { ok: false, reason: "unavailable" };
428
+ if (mixBlocked(ticketId)) return { ok: false, reason: "mix" };
429
+ if (current.length >= seatCap(ticketId)) return { ok: false, reason: "max" };
430
+ setSelectedSeats((prev) => ({ ...prev, [ticketId]: [...(prev[ticketId] ?? []), seatId] }));
431
+ return { ok: true, selected: true };
432
+ };
433
+
434
+ const clearSeats = (ticketId: string) =>
435
+ setSelectedSeats((prev) => {
436
+ if (!(ticketId in prev)) return prev;
437
+ const next = { ...prev };
438
+ delete next[ticketId];
439
+ return next;
440
+ });
441
+
442
+ /**
443
+ * Replace a tier's selection with the best `count` available seats: the
444
+ * lowest contiguous run when one exists, else the lowest seats. Bounded by
445
+ * the same cap as every other write. Returns how many were picked, so the
446
+ * surface can say "only 2 seats left together" when it is fewer than asked.
447
+ */
448
+ const pickBestAvailable = (ticketId: string, count: number): number => {
449
+ if (count > 0 && mixBlocked(ticketId)) return 0;
450
+ const wanted = Math.max(0, Math.min(count, seatCap(ticketId)));
451
+ const picked = bestAvailableSeats(seating.seats, ticketId, wanted);
452
+ setSelectedSeats((prev) => {
453
+ const next = { ...prev };
454
+ if (picked.length > 0) next[ticketId] = picked.map((s) => s.id);
455
+ else delete next[ticketId];
456
+ return next;
457
+ });
458
+ return picked.length;
459
+ };
460
+
461
+ /**
462
+ * Restore a seated selection the cart already holds for this event.
463
+ *
464
+ * The bundle checkout sends a buyer whose seats were taken back here with
465
+ * the rest of their selection still in the cart, and making them re-pick
466
+ * the seats they had would be a second refusal waiting to happen. So the
467
+ * first time the seating read lands, a cart line carrying seats becomes the
468
+ * selection: the seats per tier, the general admission quantities and the
469
+ * pay-what-you-want amounts beside them, so re-adding the event replaces
470
+ * the line with the whole of it and loses nothing. Only a line carrying
471
+ * seats does this; a plain ticket line keeps today's fresh start. A restored
472
+ * seat that no longer reads available is dropped and named by the poll
473
+ * effect below, the same as a seat taken mid-selection.
474
+ */
475
+ const restoredFromCartRef = useRef(false);
476
+ useEffect(() => {
477
+ if (restoredFromCartRef.current || !event || !seating.isFetched || !seating.seating) return;
478
+ restoredFromCartRef.current = true;
479
+ const line = ticketItems.find((t) => t.eventId === event.id);
480
+ if (!line || !cartHasSeatedLines([line])) return;
481
+ const seats: Record<string, string[]> = {};
482
+ const ga: Record<string, number> = {};
483
+ const amounts: Record<string, number> = {};
484
+ for (const [ticketId, qty] of Object.entries(line.tickets)) {
485
+ if (qty <= 0) continue;
486
+ const meta = line.ticketMeta[ticketId];
487
+ if (seating.seatedTierIds.has(ticketId)) {
488
+ const ids = (meta?.seatIds ?? []).filter((id) => seating.byId.has(id));
489
+ if (ids.length > 0) seats[ticketId] = ids;
490
+ } else {
491
+ ga[ticketId] = qty;
492
+ }
493
+ if (meta?.pwywAmount != null) amounts[ticketId] = meta.pwywAmount;
494
+ }
495
+ setSelectedSeats(seats);
496
+ setGaQuantities(ga);
497
+ if (Object.keys(amounts).length > 0) setPwywAmounts((prev) => ({ ...prev, ...amounts }));
498
+ }, [event, seating.isFetched, seating.seating, seating.byId, seating.seatedTierIds, ticketItems]);
499
+
500
+ /**
501
+ * A seat that stopped being available while selected is dropped, and named.
502
+ *
503
+ * Runs on every poll. Skips seats this buyer's own hold covers (they read
504
+ * `unavailable` to everyone once the order exists) and never fires before
505
+ * the first read has landed, so a slow network cannot empty a selection.
506
+ */
507
+ useEffect(() => {
508
+ if (!seating.isFetched || !seating.seating) return;
509
+ const gone: EventSeat[] = [];
510
+ for (const ids of Object.values(selectedSeats)) {
511
+ for (const id of ids) {
512
+ if (ownHeldSeatIds.current.has(id)) continue;
513
+ const seat = seating.byId.get(id);
514
+ if (seat && seat.status !== "available") gone.push(seat);
515
+ }
516
+ }
517
+ if (gone.length === 0) return;
518
+ const goneIds = new Set(gone.map((s) => s.id));
519
+ setSelectedSeats((prev) => {
520
+ const next: Record<string, string[]> = {};
521
+ for (const [ticketId, ids] of Object.entries(prev)) {
522
+ const kept = ids.filter((id) => !goneIds.has(id));
523
+ if (kept.length > 0) next[ticketId] = kept;
524
+ }
525
+ return next;
526
+ });
527
+ setSeatsTaken((prev) => ({ labels: [...(prev?.labels ?? []), ...gone.map((s) => s.label)] }));
528
+ }, [seating.byId, seating.isFetched, seating.seating, selectedSeats]);
257
529
 
258
530
  /**
259
531
  * The buyer's chosen amount for one PWYW tier, PER UNIT.
@@ -317,12 +589,22 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
317
589
  // picked on the way to an add-on and back. `pwywAmount` travels
318
590
  // beside it because the bundle path sends the two separately: one is
319
591
  // display, one is charged.
592
+ // A seated tier's line carries its seats (seating wave 2, docs §12):
593
+ // the ids the bundle checkout sends on the line, and the labels the
594
+ // cart prints. The quantity of such a line IS the seat count, so
595
+ // the cart offers no stepper for it; changing seats happens here.
320
596
  .map((t) => [
321
597
  t.id,
322
598
  {
323
599
  title: t.title,
324
600
  price: resolveUnitPrice(t, effectiveAmounts[t.id]),
325
601
  ...(isPayWhatYouWant(t) ? { pwywAmount: effectiveAmounts[t.id] } : {}),
602
+ ...(isSeatedTier(t.id) && (selectedSeats[t.id]?.length ?? 0) > 0
603
+ ? {
604
+ seatIds: [...selectedSeats[t.id]!],
605
+ seats: selectedSeats[t.id]!.map((id) => ({ id, label: seating.byId.get(id)?.label ?? id })),
606
+ }
607
+ : {}),
326
608
  },
327
609
  ]),
328
610
  ),
@@ -370,8 +652,20 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
370
652
  : null;
371
653
 
372
654
  try {
655
+ /**
656
+ * The chosen seats, per seated tier (seating wave 2). Omitted entirely on
657
+ * an event with no seat map, so an ordinary purchase posts the body it
658
+ * always did. Sent only for tiers in the cart, and the server requires
659
+ * the count to equal the tier's quantity, which it does by construction.
660
+ */
661
+ const seatsPayload = Object.fromEntries(
662
+ Object.entries(selectedSeats).filter(
663
+ ([ticketId, ids]) => ids.length > 0 && (selectedTickets[ticketId] ?? 0) > 0,
664
+ ),
665
+ );
373
666
  const data = await createOrder.mutateAsync({
374
667
  items: selectedTickets,
668
+ ...(Object.keys(seatsPayload).length > 0 ? { seats: seatsPayload } : {}),
375
669
  // Omitted entirely when nothing in the cart is PWYW, so an ordinary
376
670
  // purchase posts the same body it always did.
377
671
  ...(Object.keys(effectiveAmounts).length > 0 ? { amounts: effectiveAmounts } : {}),
@@ -418,6 +712,8 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
418
712
  // fee on a comp.
419
713
  setServerFeeAmount(data.feeAmount ?? 0);
420
714
  setRequestExpiresAt(data.requestExpiresAt ?? null);
715
+ // From here the seats read `unavailable` to everyone, this buyer included.
716
+ ownHeldSeatIds.current = new Set(Object.values(seatsPayload).flat());
421
717
  const origin = typeof window !== "undefined" ? window.location.origin : "";
422
718
  const ru = `${origin}${finalisePath(slug, data.orderId)}`;
423
719
  setReturnUrl(ru);
@@ -434,6 +730,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
434
730
  if (result.provider && result.provider !== "stripe") return; // Paystack redirected
435
731
  setStep("payment");
436
732
  } catch (e) {
733
+ /**
734
+ * Seats another buyer took between the read and the order (409
735
+ * `seats_taken`). Checked first: it is the one refusal where the buyer's
736
+ * details, code and card are all fine and the only fix is on the map.
737
+ * The taken seats leave the selection, the read is refreshed so the map
738
+ * shows them grey, and the buyer is put back on the tickets step with
739
+ * the seats NAMED, because "some seats were taken" sends them hunting.
740
+ */
741
+ const takenIds = seatsTakenIds(e);
742
+ if (takenIds) {
743
+ const taken = new Set(takenIds);
744
+ const labels = takenIds.map((id) => seating.byId.get(id)?.label ?? id);
745
+ setSelectedSeats((prev) => {
746
+ const next: Record<string, string[]> = {};
747
+ for (const [ticketId, ids] of Object.entries(prev)) {
748
+ const kept = ids.filter((id) => !taken.has(id));
749
+ if (kept.length > 0) next[ticketId] = kept;
750
+ }
751
+ return next;
752
+ });
753
+ setSeatsTaken({ labels });
754
+ setStep("tickets");
755
+ void seating.refetch();
756
+ return;
757
+ }
437
758
  /**
438
759
  * A members-only tier the buyer cannot have. Checked BEFORE the coupon
439
760
  * branch: a buyer who happened to type a discount code would otherwise be
@@ -533,6 +854,26 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
533
854
  setStep,
534
855
  selectedTickets,
535
856
  setTicketQty,
857
+ /**
858
+ * Reserved seating (seating wave 2). `seating` is the joined read (`null`
859
+ * seating on an event with no map); `isSeatedTier(id)` says whether a tier
860
+ * sells seats, in which case its quantity IS `selectedSeats[id].length`
861
+ * and the stepper picks or drops best-available seats. `toggleSeat` is a
862
+ * tap on the map; `pickBestAvailable` replaces a tier's selection with the
863
+ * lowest contiguous run. `seatsTaken` names seats that left the selection
864
+ * because somebody else got them (a 409, or a poll), and is state the
865
+ * buyer must act on, so a surface renders it as a visible alert.
866
+ */
867
+ seating,
868
+ isSeatedTier,
869
+ selectedSeats,
870
+ toggleSeat,
871
+ clearSeats,
872
+ pickBestAvailable,
873
+ seatsTaken,
874
+ dismissSeatsTaken: () => setSeatsTaken(null),
875
+ hasSeatedSelection,
876
+ seatSummary,
536
877
  /**
537
878
  * Raw buyer input per PWYW tier — only the tiers whose box has been touched.
538
879
  * Bind an input to this; read `effectiveAmounts` to know what will be sent.
@@ -571,8 +912,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
571
912
  // always been raised by it. The local sum did not, which is what made the
572
913
  // pre-payment "Total" a number the buyer would not be charged; the estimated
573
914
  // fee is added so the figure on screen means what it says.
574
- totalAmount:
575
- flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount + bookingFeeAmount,
915
+ totalAmount: flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount + bookingFeeAmount,
576
916
  /** GROSS ticket total, before any discount OR booking fee. */
577
917
  subTotal: coupon.quote?.subTotal ?? totalAmount,
578
918
  /**