@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
@@ -22,6 +22,16 @@ import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
22
22
  import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
23
23
  import { takeResumedCoupon } from "./resumedCoupon";
24
24
  import { useInventoryHold } from "./useInventoryHold";
25
+ import {
26
+ cartHasSeatedLines,
27
+ findSeatConflict,
28
+ seatsTakenConflict,
29
+ seatsTakenIdsOf,
30
+ type SeatsConflict,
31
+ } from "./seatConflict";
32
+ import { useForge } from "../../../provider/ForgeProvider";
33
+ import { internalPath } from "../../../runtime/pages";
34
+ import type { PublicEventSeating } from "../../../data/queries/useEvents";
25
35
  import { readAttributionRef } from "../../../utils/attribution";
26
36
  import { readLanding } from "../../../utils/landing";
27
37
  import { computeBookingFeeAmount } from "../../format/bookingFee";
@@ -116,7 +126,8 @@ function messageOf(e: unknown): string | undefined {
116
126
  export function useCheckout(opts: UseCheckoutOptions = {}) {
117
127
  const finalisePath = opts.finalisePath ?? "/checkout/finalise";
118
128
  const { user } = usePublicAuth();
119
- const { cartItems, ticketItems, isReady: isCartReady } = useCart();
129
+ const { cartItems, ticketItems, removeTicketSeats, isReady: isCartReady } = useCart();
130
+ const { client, profileId } = useForge();
120
131
  /**
121
132
  * Any cart holding tickets settles through the checkout (bundle) engine,
122
133
  * even with NO products beside them. Tickets have no single-surface order
@@ -144,11 +155,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
144
155
  cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0) +
145
156
  ticketItems.reduce(
146
157
  (sum, t) =>
147
- sum +
148
- Object.entries(t.tickets).reduce(
149
- (acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty,
150
- 0,
151
- ),
158
+ sum + Object.entries(t.tickets).reduce((acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty, 0),
152
159
  0,
153
160
  ),
154
161
  ),
@@ -222,7 +229,43 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
222
229
  */
223
230
  const [settledCheckoutId, setSettledCheckoutId] = useState<string | null>(null);
224
231
  const [startError, setStartError] = useState("");
232
+ /**
233
+ * Seats in the cart that another buyer took (seating wave 2, docs §12).
234
+ *
235
+ * Set by the re-read of `GET /public/events/:id/seating` that runs right
236
+ * before the bundle is created, or by the 409 `SEATS_TAKEN` the create
237
+ * answers when the read and the write disagree. While set, no bundle exists
238
+ * and no payment starts: the surface shows the seats by name and offers
239
+ * `chooseOtherSeats`, which prunes them from the cart and sends the buyer
240
+ * back to that event's picker. Never a substitute seat.
241
+ */
242
+ const [seatsConflict, setSeatsConflict] = useState<SeatsConflict | null>(null);
225
243
  const startedRef = useRef(false);
244
+ /** The cart's ticket lines as the create effect last saw them, for the seat check. */
245
+ const ticketItemsRef = useRef(ticketItems);
246
+ ticketItemsRef.current = ticketItems;
247
+
248
+ /**
249
+ * Re-read every seated event's availability and report the first conflict.
250
+ *
251
+ * A read that fails (network, an older API) is not a conflict: the create
252
+ * call is the authority and its 409 is handled the same way below. A
253
+ * cart with no seated line makes no request at all.
254
+ */
255
+ const checkSeats = useCallback(async (): Promise<SeatsConflict | null> => {
256
+ const items = ticketItemsRef.current;
257
+ if (!cartHasSeatedLines(items)) return null;
258
+ for (const item of items) {
259
+ try {
260
+ const res = await client.get(`/public/events/${item.eventId}/seating`, { params: { profileId } });
261
+ const conflict = findSeatConflict(item, (res.data ?? null) as PublicEventSeating | null);
262
+ if (conflict) return conflict;
263
+ } catch {
264
+ // Not ours to decide from a failed read; the create call decides.
265
+ }
266
+ }
267
+ return null;
268
+ }, [client, profileId]);
226
269
 
227
270
  const isPaidCheckout = subTotal - discountAmount + shippingCost > 0;
228
271
 
@@ -253,51 +296,79 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
253
296
  // cart keeps the flow it has always had, below.
254
297
  if (usesCheckoutEngine) {
255
298
  const enteredCode = couponCode.trim() || undefined;
256
- createCheckout
257
- .mutateAsync({
258
- firstName: guestUserData?.firstName || user?.firstName || "",
259
- lastName: guestUserData?.lastName || user?.lastName || "",
260
- email: guestUserData?.email || user?.email || "",
261
- accountId: user?.id,
262
- shippingAddress: shippingData,
263
- selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
264
- attributionRefId: readAttributionRef() ?? undefined,
265
- ...(readLanding() ?? {}),
266
- // A code typed BEFORE this call still travels with it — that is the
267
- // cheapest path and the only one available to a guest, whose bundle
268
- // does not exist yet. A code typed AFTERWARDS goes through
269
- // `/public/checkouts/apply-coupon`, which is what stops the field
270
- // freezing for a signed-in buyer whose digital-only cart enters the
271
- // payment stage on mount.
272
- couponCode: enteredCode,
273
- lines: cartToCheckoutLines(cartItems, ticketItems),
274
- })
275
- .then((r) => {
276
- setCheckoutId(r.checkoutId);
277
- // Minor → major. The bundle is the only endpoint answering in minor units.
278
- const off = (r.discountCents ?? 0) / 100;
279
- setDiscountAmount(off);
280
- // `appliedCoupons` names what came off, so an AUTOMATIC discount the
281
- // buyer never typed is labelled rather than being an unexplained
282
- // amount. The typed code is the fallback for an older API.
283
- const named = r.appliedCoupons?.[0];
284
- setAppliedCoupon(
285
- off > 0
286
- ? {
287
- code: named?.code ?? enteredCode ?? "Discount applied",
288
- discountType: null,
289
- discountValue: null,
290
- discountAmount: off,
291
- }
292
- : null,
293
- );
294
- })
295
- .catch((e) => {
296
- // A refused code fails the whole bundle, so with one entered the
297
- // reason belongs at the field the buyer used — verbatim from the API.
298
- if (enteredCode) setCouponError(messageOf(e) || "This code could not be applied.");
299
- else setStartError(messageOf(e) || "An error occurred");
300
- });
299
+ const create = () =>
300
+ createCheckout
301
+ .mutateAsync({
302
+ firstName: guestUserData?.firstName || user?.firstName || "",
303
+ lastName: guestUserData?.lastName || user?.lastName || "",
304
+ email: guestUserData?.email || user?.email || "",
305
+ accountId: user?.id,
306
+ shippingAddress: shippingData,
307
+ selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
308
+ attributionRefId: readAttributionRef() ?? undefined,
309
+ ...(readLanding() ?? {}),
310
+ // A code typed BEFORE this call still travels with it — that is the
311
+ // cheapest path and the only one available to a guest, whose bundle
312
+ // does not exist yet. A code typed AFTERWARDS goes through
313
+ // `/public/checkouts/apply-coupon`, which is what stops the field
314
+ // freezing for a signed-in buyer whose digital-only cart enters the
315
+ // payment stage on mount.
316
+ couponCode: enteredCode,
317
+ lines: cartToCheckoutLines(cartItems, ticketItems),
318
+ })
319
+ .then((r) => {
320
+ setCheckoutId(r.checkoutId);
321
+ // Minor → major. The bundle is the only endpoint answering in minor units.
322
+ const off = (r.discountCents ?? 0) / 100;
323
+ setDiscountAmount(off);
324
+ // `appliedCoupons` names what came off, so an AUTOMATIC discount the
325
+ // buyer never typed is labelled rather than being an unexplained
326
+ // amount. The typed code is the fallback for an older API.
327
+ const named = r.appliedCoupons?.[0];
328
+ setAppliedCoupon(
329
+ off > 0
330
+ ? {
331
+ code: named?.code ?? enteredCode ?? "Discount applied",
332
+ discountType: null,
333
+ discountValue: null,
334
+ discountAmount: off,
335
+ }
336
+ : null,
337
+ );
338
+ })
339
+ .catch(async (e) => {
340
+ // Seats another buyer took between the cart and this call (409
341
+ // `SEATS_TAKEN`). The server's word is final: the named seats become
342
+ // the conflict, labelled from the cart line. A 409 that names no
343
+ // seat is re-read from seating so the alert can still name them.
344
+ const takenIds = seatsTakenIdsOf(e);
345
+ if (takenIds) {
346
+ const conflict =
347
+ (takenIds.length > 0 ? seatsTakenConflict(ticketItemsRef.current, takenIds) : null) ??
348
+ (await checkSeats());
349
+ if (conflict) {
350
+ startedRef.current = false;
351
+ setSeatsConflict(conflict);
352
+ return;
353
+ }
354
+ }
355
+ // A refused code fails the whole bundle, so with one entered the
356
+ // reason belongs at the field the buyer used — verbatim from the API.
357
+ if (enteredCode) setCouponError(messageOf(e) || "This code could not be applied.");
358
+ else setStartError(messageOf(e) || "An error occurred");
359
+ });
360
+ // The seats are re-checked BEFORE the bundle exists, so a seat that went
361
+ // while the buyer was filling in an address is caught without minting a
362
+ // bundle that would only be refused, and named from a read the buyer
363
+ // can act on. The create call's own 409 covers the race after this.
364
+ void checkSeats().then((conflict) => {
365
+ if (conflict) {
366
+ startedRef.current = false;
367
+ setSeatsConflict(conflict);
368
+ return;
369
+ }
370
+ return create();
371
+ });
301
372
  return;
302
373
  }
303
374
 
@@ -423,7 +494,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
423
494
 
424
495
  // Surface the charged total (for the summary) as soon as a payment resolves.
425
496
  useEffect(() => {
426
- if (effectivePayment) setChargedTotal({ amount: effectivePayment.chargedAmount, currency: effectivePayment.chargedCurrency });
497
+ if (effectivePayment)
498
+ setChargedTotal({ amount: effectivePayment.chargedAmount, currency: effectivePayment.chargedCurrency });
427
499
  }, [effectivePayment]);
428
500
 
429
501
  // The tax quote for the summary: the coupon re-quote wins over the initial
@@ -571,7 +643,16 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
571
643
  } finally {
572
644
  setIsApplyingCoupon(false);
573
645
  }
574
- }, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
646
+ }, [
647
+ couponCode,
648
+ orderId,
649
+ checkoutId,
650
+ finalisePath,
651
+ applyCouponMutation,
652
+ applyCheckoutCouponMutation,
653
+ absorbBundleCoupon,
654
+ usesCheckoutEngine,
655
+ ]);
575
656
 
576
657
  const removeCoupon = useCallback(async () => {
577
658
  if (usesCheckoutEngine) {
@@ -629,7 +710,15 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
629
710
  } finally {
630
711
  setIsApplyingCoupon(false);
631
712
  }
632
- }, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
713
+ }, [
714
+ orderId,
715
+ checkoutId,
716
+ finalisePath,
717
+ applyCouponMutation,
718
+ applyCheckoutCouponMutation,
719
+ absorbBundleCoupon,
720
+ usesCheckoutEngine,
721
+ ]);
633
722
 
634
723
  // ── Free checkout (nothing to charge) ───────────────────────────────────────
635
724
  /** Create the (free) order and return its id so the caller can navigate to the
@@ -657,7 +746,17 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
657
746
  } finally {
658
747
  setIsFreeCheckoutLoading(false);
659
748
  }
660
- }, [subTotal, guestUserData, user, cartItems, shippingData, selectedShippingRates, appliedCoupon, joinEmailList, createOrder]);
749
+ }, [
750
+ subTotal,
751
+ guestUserData,
752
+ user,
753
+ cartItems,
754
+ shippingData,
755
+ selectedShippingRates,
756
+ appliedCoupon,
757
+ joinEmailList,
758
+ createOrder,
759
+ ]);
661
760
 
662
761
  const provider = flow.provider;
663
762
 
@@ -691,6 +790,20 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
691
790
  [paystackSession],
692
791
  );
693
792
 
793
+ /**
794
+ * The buyer's answer to a seat conflict: the taken seats leave the cart
795
+ * line (a tier or event left empty leaves with them, add-ons included) and
796
+ * the buyer goes back to that event's picker, which restores whatever of
797
+ * the selection is left. Returns the path to navigate to, or `null` when
798
+ * there is no conflict to act on.
799
+ */
800
+ const chooseOtherSeats = useCallback((): string | null => {
801
+ if (!seatsConflict) return null;
802
+ removeTicketSeats(seatsConflict.eventId, seatsConflict.seatIds);
803
+ setSeatsConflict(null);
804
+ return internalPath("events/$slug", { slug: seatsConflict.eventSlug || seatsConflict.eventId });
805
+ }, [seatsConflict, removeTicketSeats]);
806
+
694
807
  return {
695
808
  // ── Cart + totals ────────────────────────────────────────────────────────
696
809
  cartItems,
@@ -740,6 +853,16 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
740
853
  ticketItems,
741
854
  isCreatingOrder: createOrder.isPending || flow.isStarting,
742
855
  startError,
856
+ // ── Reserved seats ─────────────────────────────────────────────────────────
857
+ /**
858
+ * Seats in the cart that are no longer available, by name, for ONE event.
859
+ * Content the buyer must act on, so a surface renders it as a visible
860
+ * alert (never behind a help hint) with `chooseOtherSeats` as the action.
861
+ * `null` when every seat in the cart still reads available.
862
+ */
863
+ seatsConflict,
864
+ /** Prune the taken seats and get the event page path to send the buyer to. */
865
+ chooseOtherSeats,
743
866
  // ── Inventory hold ─────────────────────────────────────────────────────────
744
867
  /**
745
868
  * The live reservation: `isHeld` + `label` while it runs, `hasLapsed` once
@@ -0,0 +1,111 @@
1
+ import { createSection, createTable, flattenSeats, type SeatMapDocument } from "@tribe-nest/seating-core";
2
+ import type { PublicEventSeat, PublicEventSeating } from "../../../../data/queries/useEvents";
3
+
4
+ /**
5
+ * A small venue for the seating specs: a 2x4 section priced by one tier, a
6
+ * round table of four priced by another, a general admission area, a stage
7
+ * and a caption. Built with seating-core's own generators, so the labels and
8
+ * coordinates are what the admin editor would have produced.
9
+ *
10
+ * Seat ids are random (`newId`), so nothing here hard-codes one: look seats up
11
+ * by their place with `seatAt`.
12
+ */
13
+
14
+ export const STALLS_TIER = "t-stalls";
15
+ export const TABLE_TIER = "t-table";
16
+ export const GA_TIER = "t-ga";
17
+
18
+ export const seatingEvent = {
19
+ id: "ev-seated",
20
+ slug: "seated-show",
21
+ title: "Seated show",
22
+ dateTime: "2026-12-01T19:00:00.000Z",
23
+ type: "in_person",
24
+ status: "on_sale",
25
+ hasSeatMap: true,
26
+ tickets: [
27
+ { id: STALLS_TIER, title: "Stalls", description: "", price: 40, quantity: 8, order: 0, sold: 0, maxPerPerson: 3 },
28
+ { id: TABLE_TIER, title: "Table seat", description: "", price: 60, quantity: 4, order: 1, sold: 0, maxPerPerson: 4 },
29
+ { id: GA_TIER, title: "Standing", description: "", price: 20, quantity: 100, order: 2, sold: 0, maxPerPerson: 6 },
30
+ ],
31
+ media: [],
32
+ questionnaire: [],
33
+ bookingFee: null,
34
+ };
35
+
36
+ export const buildLayout = (): SeatMapDocument => {
37
+ const stalls = createSection({
38
+ name: "Stalls",
39
+ rows: 2,
40
+ seatsPerRow: 4,
41
+ seatSpacing: 20,
42
+ rowSpacing: 20,
43
+ origin: { x: 100, y: 200 },
44
+ categoryKey: "cat_stalls",
45
+ });
46
+ const table = createTable({
47
+ name: "Table 1",
48
+ center: { x: 400, y: 240 },
49
+ tableShape: "round",
50
+ radius: 30,
51
+ seatCount: 4,
52
+ categoryKey: "cat_table",
53
+ });
54
+ return {
55
+ version: 1,
56
+ canvas: { width: 800, height: 500 },
57
+ categories: [
58
+ { key: "cat_stalls", name: "Stalls", color: "#ff0000" },
59
+ { key: "cat_table", name: "Tables", color: "#00ff00" },
60
+ { key: "cat_ga", name: "GA", color: "#0000ff" },
61
+ ],
62
+ objects: [
63
+ {
64
+ id: "shape_stage",
65
+ type: "shape",
66
+ kind: "stage",
67
+ label: "Stage",
68
+ shape: { kind: "rect", x: 100, y: 40, width: 400, height: 60 },
69
+ },
70
+ { id: "text_caption", type: "text", text: "Main hall", position: { x: 100, y: 130 }, fontSize: 16 },
71
+ stalls,
72
+ table,
73
+ {
74
+ id: "ga_floor",
75
+ type: "ga",
76
+ name: "Standing floor",
77
+ capacity: 100,
78
+ categoryKey: "cat_ga",
79
+ shape: { kind: "rect", x: 100, y: 320, width: 400, height: 120 },
80
+ },
81
+ ],
82
+ };
83
+ };
84
+
85
+ export const TIER_MAPPING: Record<string, string> = { cat_stalls: STALLS_TIER, cat_table: TABLE_TIER, cat_ga: GA_TIER };
86
+
87
+ /**
88
+ * The public seating read for `layout`, every seat available unless named in
89
+ * `unavailable` as "Section/Row/Seat" (row empty for a table seat).
90
+ */
91
+ export const buildSeating = (layout: SeatMapDocument, unavailable: string[] = []): PublicEventSeating => {
92
+ const seats: PublicEventSeat[] = flattenSeats(layout).map((flat, index) => ({
93
+ id: `es-${index + 1}`,
94
+ sourceSeatId: flat.seatId,
95
+ eventTicketId: TIER_MAPPING[flat.categoryKey ?? ""]!,
96
+ status: unavailable.includes(`${flat.sectionName}/${flat.rowLabel}/${flat.seatLabel}`) ? "unavailable" : "available",
97
+ attributes: flat.attributes,
98
+ }));
99
+ return { layout, tierMapping: TIER_MAPPING, seats };
100
+ };
101
+
102
+ /** The EVENT seat id at a place, from a seating read. */
103
+ export const seatAt = (seating: PublicEventSeating, section: string, row: string, seat: string): string => {
104
+ const flat = flattenSeats(seating.layout).find(
105
+ (f) => f.sectionName === section && f.rowLabel === row && f.seatLabel === seat,
106
+ );
107
+ if (!flat) throw new Error(`no seat at ${section}/${row}/${seat}`);
108
+ const found = seating.seats.find((s) => s.sourceSeatId === flat.seatId);
109
+ if (!found) throw new Error(`seat ${flat.seatId} not in seating read`);
110
+ return found.id;
111
+ };