@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.
- 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/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -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 +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -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 +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -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/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- 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/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -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/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
/**
|