@tribe-nest/forge 3.57.0 → 3.59.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.
- package/package.json +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +25 -1
- package/src/ui/index.ts +7 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- 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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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)
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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
|
+
};
|