@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
@@ -0,0 +1,313 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import type { ReactNode } from "react";
7
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
8
+ import { PublicAuthProvider } from "../../../../contexts/PublicAuthContext";
9
+ import { CartProvider, useCart, type TicketCartItem } from "../../../../contexts/CartContext";
10
+ import { useCheckout } from "../useCheckout";
11
+ import { buildLayout, buildSeating, seatAt, GA_TIER, STALLS_TIER } from "../../event/_tests/seatingFixture";
12
+ import type { PublicEventSeating } from "../../../../data/queries/useEvents";
13
+ import { ProductDeliveryType } from "../../../../types/models";
14
+
15
+ /**
16
+ * Reserved seats through the BUNDLE checkout (seating wave 2, docs §12), over
17
+ * the real hook with only the network faked.
18
+ *
19
+ * A seat in the cart is not held, so the rules under test are the ones that
20
+ * keep a buyer from paying for a seat they will not get: the seats are
21
+ * re-read before the bundle is created, the create body names them per line,
22
+ * a seat that has gone (by the read or by the server's 409) stops payment and
23
+ * is named, and the buyer's way out prunes exactly those seats and sends
24
+ * them back to that event's picker. Nothing substitutes a seat.
25
+ */
26
+
27
+ const PROFILE_ID = "profile-1";
28
+ const EVENT_ID = "ev-seated";
29
+ const SLUG = "seated-show";
30
+
31
+ type Sent = { method: string; url: string; body: Record<string, unknown> };
32
+ let sent: Sent[] = [];
33
+ let realAdapter: AxiosAdapter | undefined;
34
+ let seating: PublicEventSeating;
35
+ let createResponse: { ok: Record<string, unknown> } | { reject: { status: number; data: Record<string, unknown> } };
36
+
37
+ function installAdapter() {
38
+ sent = [];
39
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
40
+ const url = config.url ?? "";
41
+ const body = typeof config.data === "string" ? (JSON.parse(config.data) as Record<string, unknown>) : {};
42
+ sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
43
+ const ok = (data: unknown) => ({ data, status: 200, statusText: "OK", headers: {}, config }) as never;
44
+
45
+ if (url.includes(`/public/events/${EVENT_ID}/seating`)) return ok(seating);
46
+ if (url.endsWith("/public/checkouts")) {
47
+ if ("reject" in createResponse) {
48
+ throw Object.assign(new Error("Refused"), {
49
+ isAxiosError: true,
50
+ config,
51
+ response: {
52
+ status: createResponse.reject.status,
53
+ statusText: "Conflict",
54
+ headers: {},
55
+ config,
56
+ data: createResponse.reject.data,
57
+ },
58
+ });
59
+ }
60
+ return ok(createResponse.ok);
61
+ }
62
+ if (url.includes("/public/checkouts/start-payment")) {
63
+ return ok({
64
+ paymentSecret: "pi_secret",
65
+ paymentId: "pi_1",
66
+ paymentProvider: "stripe",
67
+ amount: 100,
68
+ currency: "USD",
69
+ });
70
+ }
71
+ if (url.includes("/public/products/shipping-countries")) return ok([]);
72
+ if (url.includes("/public/currencies")) {
73
+ return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
74
+ }
75
+ return ok({});
76
+ };
77
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
78
+ axios.defaults.adapter = adapter;
79
+ }
80
+
81
+ const wrapper = ({ children }: { children: ReactNode }) => (
82
+ <QueryClientProvider
83
+ client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}
84
+ >
85
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
86
+ <PublicAuthProvider>
87
+ <CartProvider>{children}</CartProvider>
88
+ </PublicAuthProvider>
89
+ </ForgeClientProvider>
90
+ </QueryClientProvider>
91
+ );
92
+
93
+ const seatIds = () => ({
94
+ a1: seatAt(seating, "Stalls", "A", "1"),
95
+ a2: seatAt(seating, "Stalls", "A", "2"),
96
+ });
97
+
98
+ const seatedLine = (overrides: Partial<TicketCartItem> = {}): TicketCartItem => {
99
+ const { a1, a2 } = seatIds();
100
+ return {
101
+ eventId: EVENT_ID,
102
+ eventSlug: SLUG,
103
+ eventTitle: "Seated show",
104
+ tickets: { [STALLS_TIER]: 2, [GA_TIER]: 1 },
105
+ ticketMeta: {
106
+ [STALLS_TIER]: {
107
+ title: "Stalls",
108
+ price: 40,
109
+ seatIds: [a1, a2],
110
+ seats: [
111
+ { id: a1, label: "Stalls · Row A · Seat 1" },
112
+ { id: a2, label: "Stalls · Row A · Seat 2" },
113
+ ],
114
+ },
115
+ [GA_TIER]: { title: "Standing", price: 20 },
116
+ },
117
+ bookingFee: null,
118
+ ...overrides,
119
+ };
120
+ };
121
+
122
+ const addon = {
123
+ productId: "prod-poster",
124
+ productVariantId: "var-poster",
125
+ title: "Tour poster",
126
+ price: 15,
127
+ isGift: false,
128
+ canIncreaseQuantity: true,
129
+ quantity: 1,
130
+ payWhatYouWant: false,
131
+ deliveryType: ProductDeliveryType.Digital,
132
+ attachedTo: { type: "event" as const, entityId: EVENT_ID },
133
+ };
134
+
135
+ const saveCart = (tickets: TicketCartItem[], items: unknown[] = [addon]) =>
136
+ localStorage.setItem("tribenest-cart", JSON.stringify({ items, tickets }));
137
+
138
+ const mount = async () => {
139
+ const hook = renderHook(() => ({ checkout: useCheckout(), cart: useCart() }), { wrapper });
140
+ await waitFor(() => expect(hook.result.current.checkout.isCartReady).toBe(true));
141
+ await waitFor(() => expect(hook.result.current.checkout.ticketItems.length).toBeGreaterThan(0));
142
+ return hook;
143
+ };
144
+
145
+ const enterPayment = (result: { current: { checkout: ReturnType<typeof useCheckout> } }) =>
146
+ act(() => {
147
+ result.current.checkout.submitGuest({ firstName: "Fan", lastName: "Person", email: "fan@example.com" });
148
+ });
149
+
150
+ const createCalls = () => sent.filter((r) => r.method === "post" && r.url.endsWith("/public/checkouts"));
151
+ const startPaymentCalls = () => sent.filter((r) => r.url.includes("/start-payment"));
152
+
153
+ beforeEach(() => {
154
+ localStorage.clear();
155
+ seating = buildSeating(buildLayout());
156
+ createResponse = {
157
+ ok: { checkoutId: "checkout-1", currency: "USD", subtotalCents: 11500, discountCents: 0, totalCents: 11500 },
158
+ };
159
+ installAdapter();
160
+ });
161
+
162
+ afterEach(() => {
163
+ if (realAdapter) axios.defaults.adapter = realAdapter;
164
+ localStorage.clear();
165
+ });
166
+
167
+ describe("useCheckout: reserved seats in the bundle", () => {
168
+ it("re-reads seating before creating the bundle and sends the seat ids per seated line", async () => {
169
+ saveCart([seatedLine()]);
170
+ const { a1, a2 } = seatIds();
171
+ const { result } = await mount();
172
+ enterPayment(result);
173
+
174
+ await waitFor(() => expect(result.current.checkout.checkoutId).toBe("checkout-1"));
175
+ // The read came first, then the create.
176
+ const seatingRead = sent.findIndex((r) => r.url.includes(`/public/events/${EVENT_ID}/seating`));
177
+ const create = sent.findIndex((r) => r.method === "post" && r.url.endsWith("/public/checkouts"));
178
+ expect(seatingRead).toBeGreaterThanOrEqual(0);
179
+ expect(create).toBeGreaterThan(seatingRead);
180
+
181
+ const lines = createCalls()[0]!.body.lines as Record<string, unknown>[];
182
+ expect(lines).toEqual([
183
+ expect.objectContaining({ type: "event_ticket", ticketId: STALLS_TIER, quantity: 2, seatIds: [a1, a2] }),
184
+ expect.objectContaining({ type: "event_ticket", ticketId: GA_TIER, quantity: 1 }),
185
+ expect.objectContaining({ type: "product", productId: "prod-poster" }),
186
+ ]);
187
+ expect(lines[1]).not.toHaveProperty("seatIds");
188
+ expect(result.current.checkout.seatsConflict).toBeNull();
189
+ await waitFor(() => expect(startPaymentCalls().length).toBe(1));
190
+ });
191
+
192
+ it("a seat that has gone by the pre-payment read stops the bundle before it is created, and names the seat", async () => {
193
+ saveCart([seatedLine()]);
194
+ const { a2 } = seatIds();
195
+ // A2 went to another buyer while this one was filling in their details.
196
+ seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
197
+ const { result } = await mount();
198
+ enterPayment(result);
199
+
200
+ await waitFor(() => expect(result.current.checkout.seatsConflict).not.toBeNull());
201
+ expect(result.current.checkout.seatsConflict).toEqual({
202
+ eventId: EVENT_ID,
203
+ eventSlug: SLUG,
204
+ eventTitle: "Seated show",
205
+ seatIds: [a2],
206
+ labels: ["Stalls · Row A · Seat 2"],
207
+ });
208
+ expect(createCalls()).toHaveLength(0);
209
+ expect(startPaymentCalls()).toHaveLength(0);
210
+ expect(result.current.checkout.checkoutId).toBeNull();
211
+ expect(result.current.checkout.startError).toBe("");
212
+ });
213
+
214
+ it("a 409 SEATS_TAKEN from the create is authoritative: no payment starts, the seats are named", async () => {
215
+ saveCart([seatedLine()]);
216
+ const { a1 } = seatIds();
217
+ createResponse = {
218
+ reject: {
219
+ status: 409,
220
+ data: { code: "SEATS_TAKEN", message: "Some of those seats were just taken.", details: { eventSeatIds: [a1] } },
221
+ },
222
+ };
223
+ const { result } = await mount();
224
+ enterPayment(result);
225
+
226
+ await waitFor(() => expect(result.current.checkout.seatsConflict).not.toBeNull());
227
+ expect(result.current.checkout.seatsConflict).toMatchObject({
228
+ eventId: EVENT_ID,
229
+ seatIds: [a1],
230
+ labels: ["Stalls · Row A · Seat 1"],
231
+ });
232
+ expect(createCalls()).toHaveLength(1);
233
+ expect(startPaymentCalls()).toHaveLength(0);
234
+ expect(result.current.checkout.checkoutId).toBeNull();
235
+ // Not a generic failure and not a coupon refusal.
236
+ expect(result.current.checkout.startError).toBe("");
237
+ expect(result.current.checkout.couponError).toBe("");
238
+ });
239
+
240
+ it("any other refusal is still the ordinary start error", async () => {
241
+ saveCart([seatedLine()]);
242
+ createResponse = { reject: { status: 409, data: { code: "INVENTORY_HOLD_EXPIRED", message: "Sold out." } } };
243
+ const { result } = await mount();
244
+ enterPayment(result);
245
+
246
+ await waitFor(() => expect(result.current.checkout.startError).toBe("Sold out."));
247
+ expect(result.current.checkout.seatsConflict).toBeNull();
248
+ });
249
+
250
+ it("choose other seats prunes exactly the taken seats from the line and returns the event page path", async () => {
251
+ saveCart([seatedLine()]);
252
+ const { a1, a2 } = seatIds();
253
+ seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
254
+ const { result } = await mount();
255
+ enterPayment(result);
256
+ await waitFor(() => expect(result.current.checkout.seatsConflict).not.toBeNull());
257
+
258
+ let path: string | null = null;
259
+ act(() => {
260
+ path = result.current.checkout.chooseOtherSeats();
261
+ });
262
+ expect(path).toBe(`/i/events/${SLUG}`);
263
+ expect(result.current.checkout.seatsConflict).toBeNull();
264
+
265
+ const line = result.current.cart.ticketItems.find((t) => t.eventId === EVENT_ID);
266
+ expect(line?.tickets).toEqual({ [STALLS_TIER]: 1, [GA_TIER]: 1 });
267
+ expect(line?.ticketMeta[STALLS_TIER]).toEqual({
268
+ title: "Stalls",
269
+ price: 40,
270
+ seatIds: [a1],
271
+ seats: [{ id: a1, label: "Stalls · Row A · Seat 1" }],
272
+ });
273
+ // The GA tier and the add-on are untouched.
274
+ expect(line?.ticketMeta[GA_TIER]).toEqual({ title: "Standing", price: 20 });
275
+ expect(result.current.cart.cartItems.map((i) => i.productId)).toEqual(["prod-poster"]);
276
+ // Persisted, so the event page reads the pruned line.
277
+ const saved = JSON.parse(localStorage.getItem("tribenest-cart") ?? "{}") as { tickets: TicketCartItem[] };
278
+ expect(saved.tickets[0]?.ticketMeta[STALLS_TIER]?.seatIds).toEqual([a1]);
279
+ expect(a2).not.toEqual(a1);
280
+ });
281
+
282
+ it("when every seat of the only line is gone the event leaves the cart, add-ons with it", async () => {
283
+ const { a1, a2 } = seatIds();
284
+ saveCart([
285
+ seatedLine({
286
+ tickets: { [STALLS_TIER]: 2 },
287
+ ticketMeta: { [STALLS_TIER]: seatedLine().ticketMeta[STALLS_TIER]! },
288
+ }),
289
+ ]);
290
+ seating = buildSeating(buildLayout(), ["Stalls/A/1", "Stalls/A/2"]);
291
+ const { result } = await mount();
292
+ enterPayment(result);
293
+ await waitFor(() => expect(result.current.checkout.seatsConflict?.seatIds).toEqual([a1, a2]));
294
+
295
+ act(() => {
296
+ result.current.checkout.chooseOtherSeats();
297
+ });
298
+ expect(result.current.cart.ticketItems).toEqual([]);
299
+ expect(result.current.cart.cartItems).toEqual([]);
300
+ });
301
+
302
+ it("a cart with no seated line makes no seating read and creates the bundle as before", async () => {
303
+ saveCart([seatedLine({ tickets: { [GA_TIER]: 2 }, ticketMeta: { [GA_TIER]: { title: "Standing", price: 20 } } })]);
304
+ const { result } = await mount();
305
+ enterPayment(result);
306
+
307
+ await waitFor(() => expect(result.current.checkout.checkoutId).toBe("checkout-1"));
308
+ expect(sent.some((r) => r.url.includes("/seating"))).toBe(false);
309
+ const lines = createCalls()[0]!.body.lines as Record<string, unknown>[];
310
+ expect(lines[0]).toEqual(expect.objectContaining({ type: "event_ticket", ticketId: GA_TIER, quantity: 2 }));
311
+ expect(lines[0]).not.toHaveProperty("seatIds");
312
+ });
313
+ });
@@ -0,0 +1,117 @@
1
+ import type { TicketCartItem } from "../../../contexts/CartContext";
2
+ import type { PublicEventSeating } from "../../../data/queries/useEvents";
3
+ import { indexEventSeats } from "../event/useEventSeating";
4
+
5
+ /**
6
+ * Reserved seats in the bundle cart, and what happens when they are gone.
7
+ *
8
+ * A seat in the cart is NOT held: the bundle takes its holds when
9
+ * `POST /public/checkouts` runs, which may be minutes after the seat was
10
+ * picked on the map. So the checkout re-reads seating right before that call
11
+ * and again treats the server's 409 `SEATS_TAKEN` as the last word. Either
12
+ * way the answer is the same shape, a `SeatsConflict`, and the buyer's next
13
+ * move is the same: back to THAT event's picker with the taken seats named.
14
+ * Nothing here ever substitutes a seat (owner decision, docs §12).
15
+ *
16
+ * Pure functions, so they are asserted without a provider tree.
17
+ */
18
+
19
+ /** Seats of one event that can no longer be bought as chosen. */
20
+ export interface SeatsConflict {
21
+ eventId: string;
22
+ eventSlug: string;
23
+ eventTitle: string;
24
+ /** Event seat ids to take out of the cart line. */
25
+ seatIds: string[];
26
+ /** Their labels, in the same order, for the alert. */
27
+ labels: string[];
28
+ }
29
+
30
+ /** The tiers of a selection that carry seats: the lines a seating re-check covers. */
31
+ export const seatedLinesOf = (item: TicketCartItem): { ticketId: string; seatIds: string[] }[] =>
32
+ Object.entries(item.ticketMeta)
33
+ .filter(([ticketId, meta]) => (meta.seatIds?.length ?? 0) > 0 && (item.tickets[ticketId] ?? 0) > 0)
34
+ .map(([ticketId, meta]) => ({ ticketId, seatIds: meta.seatIds ?? [] }));
35
+
36
+ /** Whether any event in the cart holds a seated line worth re-checking. */
37
+ export const cartHasSeatedLines = (ticketItems: readonly TicketCartItem[]): boolean =>
38
+ ticketItems.some((item) => seatedLinesOf(item).length > 0);
39
+
40
+ /** The label a cart line carries for a seat, else the seating read's, else the id. */
41
+ const labelFor = (item: TicketCartItem, seatId: string, seating?: PublicEventSeating | null): string => {
42
+ for (const meta of Object.values(item.ticketMeta)) {
43
+ const seat = meta.seats?.find((s) => s.id === seatId);
44
+ if (seat) return seat.label;
45
+ }
46
+ if (seating) {
47
+ const found = indexEventSeats(seating).byId.get(seatId);
48
+ if (found) return found.label;
49
+ }
50
+ return seatId;
51
+ };
52
+
53
+ const conflictOf = (item: TicketCartItem, seatIds: string[], seating?: PublicEventSeating | null): SeatsConflict => ({
54
+ eventId: item.eventId,
55
+ eventSlug: item.eventSlug,
56
+ eventTitle: item.eventTitle,
57
+ seatIds,
58
+ labels: seatIds.map((id) => labelFor(item, id, seating)),
59
+ });
60
+
61
+ /**
62
+ * Compare a cart line's seats with a fresh seating read.
63
+ *
64
+ * A seat that does not read `available` is a conflict, and so is a seat the
65
+ * read no longer lists (a map rebound since the seat was picked). `null` when
66
+ * every seat is still free, and when the read is `null`: an event that has
67
+ * lost its map sells plain quantities, and the server decides what that means
68
+ * for a line still naming seats.
69
+ */
70
+ export function findSeatConflict(
71
+ item: TicketCartItem,
72
+ seating: PublicEventSeating | null | undefined,
73
+ ): SeatsConflict | null {
74
+ if (!seating) return null;
75
+ const status = new Map(seating.seats.map((seat) => [seat.id, seat.status]));
76
+ const gone: string[] = [];
77
+ for (const line of seatedLinesOf(item)) {
78
+ for (const id of line.seatIds) {
79
+ if (status.get(id) !== "available") gone.push(id);
80
+ }
81
+ }
82
+ return gone.length > 0 ? conflictOf(item, gone, seating) : null;
83
+ }
84
+
85
+ /**
86
+ * A 409 from `POST /public/checkouts` naming seats another buyer took
87
+ * (`code: "SEATS_TAKEN"`, `details.eventSeatIds`). The ids, or `null` when the
88
+ * error is anything else, including a 409 of another kind.
89
+ */
90
+ export function seatsTakenIdsOf(e: unknown): string[] | null {
91
+ const res = (e as { response?: { status?: number; data?: { code?: unknown; details?: { eventSeatIds?: unknown } } } })
92
+ ?.response;
93
+ if (res?.status !== 409) return null;
94
+ const ids = res.data?.details?.eventSeatIds;
95
+ if (Array.isArray(ids) && ids.every((id) => typeof id === "string")) return ids as string[];
96
+ return res.data?.code === "SEATS_TAKEN" ? [] : null;
97
+ }
98
+
99
+ /**
100
+ * Attribute the seats a 409 named to the event whose cart line holds them.
101
+ *
102
+ * The response names seats, not events, and a cart can hold several seated
103
+ * events. The first event holding any of the ids is the conflict surfaced
104
+ * (one alert at a time; the next event is re-checked when the buyer returns).
105
+ * Ids no line holds are ignored: there is nothing in the cart to prune.
106
+ */
107
+ export function seatsTakenConflict(
108
+ ticketItems: readonly TicketCartItem[],
109
+ eventSeatIds: readonly string[],
110
+ ): SeatsConflict | null {
111
+ const named = new Set(eventSeatIds);
112
+ for (const item of ticketItems) {
113
+ const held = seatedLinesOf(item).flatMap((line) => line.seatIds.filter((id) => named.has(id)));
114
+ if (held.length > 0) return conflictOf(item, held);
115
+ }
116
+ return null;
117
+ }