@tribe-nest/forge 3.57.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 (71) 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/useEvents.ts +68 -0
  7. package/src/data/queries/useMyBookings.ts +16 -0
  8. package/src/data/queries/useNotifications.ts +1 -1
  9. package/src/i18n/de.json +79 -0
  10. package/src/i18n/en.json +79 -0
  11. package/src/index.ts +1 -0
  12. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  13. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  14. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  15. package/src/server/index.ts +1 -1
  16. package/src/server/pwa.ts +1 -1
  17. package/src/types/models.ts +10 -0
  18. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  19. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  20. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  21. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  22. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  23. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  24. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  25. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  26. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  27. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  28. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  29. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  30. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  31. package/src/ui/headless/event/useEventSeating.ts +233 -0
  32. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  33. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  34. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  35. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  36. package/src/ui/headless/index.ts +25 -1
  37. package/src/ui/index.ts +7 -0
  38. package/src/ui/media/CallStage.tsx +107 -10
  39. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  40. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  41. package/src/ui/media/_tests/callState.spec.ts +103 -1
  42. package/src/ui/media/callBreakouts.ts +57 -0
  43. package/src/ui/media/callState.ts +83 -23
  44. package/src/ui/media/index.ts +11 -0
  45. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  46. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  47. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  48. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  49. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  50. package/src/ui/shell/TribeNestApp.tsx +1 -1
  51. package/src/ui/styled/AccountDashboard.tsx +5 -0
  52. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  53. package/src/ui/styled/Cart.tsx +211 -135
  54. package/src/ui/styled/Checkout.tsx +163 -32
  55. package/src/ui/styled/EmailListForm.tsx +7 -9
  56. package/src/ui/styled/EventTickets.tsx +143 -4
  57. package/src/ui/styled/PhoneInput.tsx +127 -0
  58. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  59. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  60. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  61. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  62. package/src/ui/styled/forge-utilities.css +67 -0
  63. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  64. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  65. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  66. package/src/ui/styled/seating/paint.ts +79 -0
  67. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  68. package/src/ui/styled/seating/viewport.ts +86 -0
  69. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  70. package/src/utils/engagementTracker.ts +73 -0
  71. package/src/utils/landing.ts +1 -1
@@ -0,0 +1,443 @@
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 } from "../../../../contexts/CartContext";
10
+ import { useEventCheckout } from "../useEventCheckout";
11
+ import { buildLayout, buildSeating, GA_TIER, seatAt, seatingEvent, STALLS_TIER, TABLE_TIER } from "./seatingFixture";
12
+ import type { PublicEventSeating } from "../../../../data/queries/useEvents";
13
+
14
+ /**
15
+ * Seat selection inside the ticket checkout (seating wave 2), through the
16
+ * REAL hook with only the network faked.
17
+ *
18
+ * The rules under test are the ones a buyer would otherwise discover by being
19
+ * refused: a seated tier's quantity IS its seat selection, the per-person cap
20
+ * applies to seats, the order carries the seats, and seats another buyer took
21
+ * leave the selection with their names rather than as a generic failure.
22
+ */
23
+
24
+ const PROFILE_ID = "profile-1";
25
+ const SLUG = seatingEvent.slug;
26
+ const EVENT_ID = seatingEvent.id;
27
+
28
+ type Sent = { method: string; url: string; body: Record<string, unknown> };
29
+ let sent: Sent[] = [];
30
+ let realAdapter: AxiosAdapter | undefined;
31
+ let seating: PublicEventSeating;
32
+ let orderResponse: Record<string, unknown> | { reject: Record<string, unknown> };
33
+
34
+ function installAdapter() {
35
+ sent = [];
36
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
37
+ const url = config.url ?? "";
38
+ const body = typeof config.data === "string" ? (JSON.parse(config.data) as Record<string, unknown>) : {};
39
+ sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
40
+ const ok = (data: unknown) => ({ data, status: 200, statusText: "OK", headers: {}, config }) as never;
41
+
42
+ if (url.includes(`/public/events/${EVENT_ID}/seating`)) return ok(seating);
43
+ if (url.includes(`/public/events/${SLUG}`)) return ok(seatingEvent);
44
+ if (url.includes(`/public/events/${EVENT_ID}/orders`)) {
45
+ if ("reject" in orderResponse) {
46
+ throw Object.assign(new Error("Conflict"), {
47
+ isAxiosError: true,
48
+ config,
49
+ response: { status: 409, statusText: "Conflict", headers: {}, config, data: orderResponse.reject },
50
+ });
51
+ }
52
+ return ok(orderResponse);
53
+ }
54
+ if (url.includes(`/public/events/${EVENT_ID}/start-payment`)) {
55
+ return ok({ paymentSecret: "pi_secret", paymentId: "pi_1", paymentProvider: "stripe", totalAmount: 80 });
56
+ }
57
+ if (url.includes("/public/currencies")) {
58
+ return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
59
+ }
60
+ return ok({});
61
+ };
62
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
63
+ axios.defaults.adapter = adapter;
64
+ }
65
+
66
+ const wrapper = ({ children }: { children: ReactNode }) => (
67
+ <QueryClientProvider
68
+ client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}
69
+ >
70
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
71
+ <PublicAuthProvider>
72
+ <CartProvider>{children}</CartProvider>
73
+ </PublicAuthProvider>
74
+ </ForgeClientProvider>
75
+ </QueryClientProvider>
76
+ );
77
+
78
+ const mount = async () => {
79
+ const hook = renderHook(() => useEventCheckout(SLUG), { wrapper });
80
+ await waitFor(() => expect(hook.result.current.event?.id).toBe(EVENT_ID));
81
+ await waitFor(() => expect(hook.result.current.seating.isFetched).toBe(true));
82
+ return hook;
83
+ };
84
+
85
+ const fillDetails = (result: { current: ReturnType<typeof useEventCheckout> }) =>
86
+ act(() => {
87
+ result.current.setFirstName("Fan");
88
+ result.current.setLastName("Person");
89
+ result.current.setEmail("fan@example.com");
90
+ });
91
+
92
+ const labelsOf = (result: { current: ReturnType<typeof useEventCheckout> }, tierId: string) =>
93
+ (result.current.selectedSeats[tierId] ?? []).map((id) => result.current.seating.byId.get(id)?.label);
94
+
95
+ beforeEach(() => {
96
+ localStorage.clear();
97
+ seating = buildSeating(buildLayout());
98
+ orderResponse = {
99
+ orderId: "order-1",
100
+ totalAmount: 80,
101
+ subTotal: 80,
102
+ feeAmount: 0,
103
+ holdExpiresAt: "2026-12-01T18:00:00.000Z",
104
+ };
105
+ installAdapter();
106
+ });
107
+
108
+ afterEach(() => {
109
+ if (realAdapter) axios.defaults.adapter = realAdapter;
110
+ localStorage.clear();
111
+ });
112
+
113
+ describe("useEventCheckout: reserved seats", () => {
114
+ it("a seated tier's quantity is the size of its seat selection", async () => {
115
+ const { result } = await mount();
116
+ expect(result.current.isSeatedTier(STALLS_TIER)).toBe(true);
117
+ expect(result.current.isSeatedTier(TABLE_TIER)).toBe(true);
118
+ // Mapped through a GA area, so no seats: a plain quantity tier.
119
+ expect(result.current.isSeatedTier(GA_TIER)).toBe(false);
120
+ const a1 = seatAt(seating, "Stalls", "A", "1");
121
+ const a2 = seatAt(seating, "Stalls", "A", "2");
122
+
123
+ act(() => {
124
+ expect(result.current.toggleSeat(STALLS_TIER, a1)).toEqual({ ok: true, selected: true });
125
+ });
126
+ act(() => {
127
+ expect(result.current.toggleSeat(STALLS_TIER, a2)).toEqual({ ok: true, selected: true });
128
+ });
129
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 2 });
130
+ expect(result.current.ticketCount).toBe(2);
131
+ expect(result.current.subTotal).toBe(80);
132
+
133
+ act(() => {
134
+ expect(result.current.toggleSeat(STALLS_TIER, a1)).toEqual({ ok: true, selected: false });
135
+ });
136
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 1 });
137
+ expect(result.current.selectedSeats).toEqual({ [STALLS_TIER]: [a2] });
138
+
139
+ act(() => result.current.clearSeats(STALLS_TIER));
140
+ expect(result.current.selectedTickets).toEqual({});
141
+ expect(result.current.hasSeatedSelection).toBe(false);
142
+ });
143
+
144
+ it("the +/- stepper picks and drops best-available seats on a seated tier", async () => {
145
+ const { result } = await mount();
146
+ act(() => result.current.setTicketQty(STALLS_TIER, 2));
147
+ expect(labelsOf(result, STALLS_TIER)).toEqual(["Stalls · Row A · Seat 1", "Stalls · Row A · Seat 2"]);
148
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 2 });
149
+
150
+ // "+" adds the neighbour, building the party rather than scattering it.
151
+ act(() => result.current.setTicketQty(STALLS_TIER, 3));
152
+ expect(labelsOf(result, STALLS_TIER)).toEqual([
153
+ "Stalls · Row A · Seat 1",
154
+ "Stalls · Row A · Seat 2",
155
+ "Stalls · Row A · Seat 3",
156
+ ]);
157
+
158
+ // "-" drops the most recently picked seat, not the first.
159
+ act(() => result.current.setTicketQty(STALLS_TIER, 1));
160
+ expect(labelsOf(result, STALLS_TIER)).toEqual(["Stalls · Row A · Seat 1"]);
161
+ act(() => result.current.setTicketQty(STALLS_TIER, 0));
162
+ expect(result.current.selectedSeats).toEqual({});
163
+
164
+ // A GA tier with no seats on the map keeps the plain number.
165
+ act(() => result.current.setTicketQty(GA_TIER, 2));
166
+ expect(result.current.selectedTickets).toEqual({ [GA_TIER]: 2 });
167
+ expect(result.current.selectedSeats).toEqual({});
168
+ });
169
+
170
+ it("best available picks the lowest contiguous run and replaces the selection", async () => {
171
+ seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
172
+ const { result } = await mount();
173
+ let picked = 0;
174
+ act(() => {
175
+ picked = result.current.pickBestAvailable(STALLS_TIER, 3);
176
+ });
177
+ expect(picked).toBe(3);
178
+ expect(labelsOf(result, STALLS_TIER)).toEqual([
179
+ "Stalls · Row B · Seat 1",
180
+ "Stalls · Row B · Seat 2",
181
+ "Stalls · Row B · Seat 3",
182
+ ]);
183
+ act(() => {
184
+ picked = result.current.pickBestAvailable(STALLS_TIER, 2);
185
+ });
186
+ expect(picked).toBe(2);
187
+ expect(labelsOf(result, STALLS_TIER)).toEqual(["Stalls · Row A · Seat 3", "Stalls · Row A · Seat 4"]);
188
+ });
189
+
190
+ it("maxPerPerson stops a fourth seat on a tier capped at three, by tap and by stepper", async () => {
191
+ const { result } = await mount();
192
+ const ids = ["1", "2", "3", "4"].map((n) => seatAt(seating, "Stalls", "A", n));
193
+ for (const id of ids.slice(0, 3)) {
194
+ act(() => {
195
+ expect(result.current.toggleSeat(STALLS_TIER, id).ok).toBe(true);
196
+ });
197
+ }
198
+ act(() => {
199
+ expect(result.current.toggleSeat(STALLS_TIER, ids[3]!)).toEqual({ ok: false, reason: "max" });
200
+ });
201
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 3 });
202
+
203
+ act(() => result.current.setTicketQty(STALLS_TIER, 5));
204
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 3 });
205
+ let picked = 0;
206
+ act(() => {
207
+ picked = result.current.pickBestAvailable(STALLS_TIER, 8);
208
+ });
209
+ expect(picked).toBe(3);
210
+ });
211
+
212
+ it("refuses an unavailable seat and a seat of another tier", async () => {
213
+ seating = buildSeating(buildLayout(), ["Stalls/A/1"]);
214
+ const { result } = await mount();
215
+ const a1 = seatAt(seating, "Stalls", "A", "1");
216
+ const table1 = seatAt(seating, "Table 1", "", "1");
217
+ act(() => {
218
+ expect(result.current.toggleSeat(STALLS_TIER, a1)).toEqual({ ok: false, reason: "unavailable" });
219
+ expect(result.current.toggleSeat(STALLS_TIER, table1)).toEqual({ ok: false, reason: "unknown" });
220
+ expect(result.current.toggleSeat(TABLE_TIER, table1).ok).toBe(true);
221
+ });
222
+ expect(result.current.selectedTickets).toEqual({ [TABLE_TIER]: 1 });
223
+ });
224
+
225
+ it("continueToPayment sends the seats per seated tier, and nothing extra on a GA-only cart", async () => {
226
+ const { result } = await mount();
227
+ const b1 = seatAt(seating, "Stalls", "B", "1");
228
+ const b2 = seatAt(seating, "Stalls", "B", "2");
229
+ act(() => {
230
+ result.current.toggleSeat(STALLS_TIER, b1);
231
+ });
232
+ act(() => {
233
+ result.current.toggleSeat(STALLS_TIER, b2);
234
+ });
235
+ await fillDetails(result);
236
+ await act(async () => {
237
+ await result.current.continueToPayment();
238
+ });
239
+
240
+ const order = sent.find((r) => r.method === "post" && r.url.includes("/orders"));
241
+ expect(order?.body).toMatchObject({
242
+ items: { [STALLS_TIER]: 2 },
243
+ seats: { [STALLS_TIER]: [b1, b2] },
244
+ email: "fan@example.com",
245
+ });
246
+ await waitFor(() => expect(result.current.step).toBe("payment"));
247
+ expect(result.current.seatSummary).toEqual([
248
+ {
249
+ ticketId: STALLS_TIER,
250
+ title: "Stalls",
251
+ seats: [
252
+ { id: b1, label: "Stalls · Row B · Seat 1" },
253
+ { id: b2, label: "Stalls · Row B · Seat 2" },
254
+ ],
255
+ },
256
+ ]);
257
+
258
+ // A cart with no seated selection posts the body it always did.
259
+ sent = [];
260
+ const ga = await mount();
261
+ act(() => ga.result.current.setTicketQty(GA_TIER, 2));
262
+ await fillDetails(ga.result);
263
+ await act(async () => {
264
+ await ga.result.current.continueToPayment();
265
+ });
266
+ const gaOrder = sent.find((r) => r.method === "post" && r.url.includes("/orders"));
267
+ expect(gaOrder?.body).toMatchObject({ items: { [GA_TIER]: 2 } });
268
+ expect(gaOrder?.body).not.toHaveProperty("seats");
269
+ });
270
+
271
+ it("a 409 seats_taken drops the named seats, refreshes the read, names them, and returns to the tickets step", async () => {
272
+ const { result } = await mount();
273
+ const a1 = seatAt(seating, "Stalls", "A", "1");
274
+ const a2 = seatAt(seating, "Stalls", "A", "2");
275
+ act(() => {
276
+ result.current.toggleSeat(STALLS_TIER, a1);
277
+ });
278
+ act(() => {
279
+ result.current.toggleSeat(STALLS_TIER, a2);
280
+ });
281
+ await fillDetails(result);
282
+ act(() => result.current.goToDetails());
283
+ expect(result.current.step).toBe("details");
284
+
285
+ orderResponse = { reject: { message: "Some of those seats were just taken.", details: { eventSeatIds: [a2] } } };
286
+ // The refreshed read agrees: A2 is gone.
287
+ seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
288
+ const seatingReadsBefore = sent.filter((r) => r.url.includes("/seating")).length;
289
+
290
+ await act(async () => {
291
+ await result.current.continueToPayment();
292
+ });
293
+
294
+ expect(result.current.step).toBe("tickets");
295
+ expect(result.current.selectedSeats).toEqual({ [STALLS_TIER]: [a1] });
296
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 1 });
297
+ expect(result.current.seatsTaken).toEqual({ labels: ["Stalls · Row A · Seat 2"] });
298
+ // Not a generic failure, and not a coupon or a gate.
299
+ expect(result.current.error).toBeNull();
300
+ expect(result.current.gateRefusal).toBeNull();
301
+ await waitFor(() =>
302
+ expect(sent.filter((r) => r.url.includes("/seating")).length).toBeGreaterThan(seatingReadsBefore),
303
+ );
304
+ expect(sent.some((r) => r.url.includes("/start-payment"))).toBe(false);
305
+
306
+ act(() => result.current.dismissSeatsTaken());
307
+ expect(result.current.seatsTaken).toBeNull();
308
+ });
309
+
310
+ it("a selected seat that reads unavailable on a later poll is dropped with the same notice", async () => {
311
+ const { result } = await mount();
312
+ const a1 = seatAt(seating, "Stalls", "A", "1");
313
+ const a2 = seatAt(seating, "Stalls", "A", "2");
314
+ act(() => {
315
+ result.current.toggleSeat(STALLS_TIER, a1);
316
+ });
317
+ act(() => {
318
+ result.current.toggleSeat(STALLS_TIER, a2);
319
+ });
320
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 2 });
321
+
322
+ seating = buildSeating(buildLayout(), ["Stalls/A/1"]);
323
+ await act(async () => {
324
+ await result.current.seating.refetch();
325
+ });
326
+
327
+ await waitFor(() => expect(result.current.selectedSeats).toEqual({ [STALLS_TIER]: [a2] }));
328
+ expect(result.current.seatsTaken).toEqual({ labels: ["Stalls · Row A · Seat 1"] });
329
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 1 });
330
+ });
331
+
332
+ it("a seated selection goes to the bundle cart with its seat ids and labels, and a GA line is unchanged", async () => {
333
+ const { result } = await mount();
334
+ const a1 = seatAt(seating, "Stalls", "A", "1");
335
+ const a2 = seatAt(seating, "Stalls", "A", "2");
336
+ act(() => {
337
+ result.current.toggleSeat(STALLS_TIER, a1);
338
+ });
339
+ act(() => {
340
+ result.current.toggleSeat(STALLS_TIER, a2);
341
+ });
342
+ act(() => result.current.setTicketQty(GA_TIER, 1));
343
+ expect(result.current.hasSeatedSelection).toBe(true);
344
+
345
+ let added: boolean | undefined;
346
+ act(() => {
347
+ added = result.current.addTicketsToCart();
348
+ });
349
+ expect(added).toBe(true);
350
+ expect(result.current.ticketsInCart).toBe(true);
351
+ expect(result.current.error).toBeNull();
352
+
353
+ // The persisted line, as the cart and the bundle checkout will read it.
354
+ const saved = JSON.parse(localStorage.getItem("tribenest-cart") ?? "{}") as {
355
+ tickets: {
356
+ eventId: string;
357
+ eventSlug: string;
358
+ tickets: Record<string, number>;
359
+ ticketMeta: Record<string, unknown>;
360
+ }[];
361
+ };
362
+ expect(saved.tickets).toHaveLength(1);
363
+ const line = saved.tickets[0]!;
364
+ expect(line.eventId).toBe(EVENT_ID);
365
+ expect(line.eventSlug).toBe(SLUG);
366
+ // A seated tier's quantity is its seat count.
367
+ expect(line.tickets).toEqual({ [STALLS_TIER]: 2, [GA_TIER]: 1 });
368
+ expect(line.ticketMeta[STALLS_TIER]).toEqual({
369
+ title: "Stalls",
370
+ price: 40,
371
+ seatIds: [a1, a2],
372
+ seats: [
373
+ { id: a1, label: "Stalls · Row A · Seat 1" },
374
+ { id: a2, label: "Stalls · Row A · Seat 2" },
375
+ ],
376
+ });
377
+ // A general admission tier carries no seat fields at all.
378
+ expect(line.ticketMeta[GA_TIER]).toEqual({ title: "Standing", price: 20 });
379
+ });
380
+
381
+ it("restores a seated selection the cart already holds for this event, dropping a seat that has since gone", async () => {
382
+ const a1 = seatAt(seating, "Stalls", "A", "1");
383
+ const a2 = seatAt(seating, "Stalls", "A", "2");
384
+ localStorage.setItem(
385
+ "tribenest-cart",
386
+ JSON.stringify({
387
+ items: [],
388
+ tickets: [
389
+ {
390
+ eventId: EVENT_ID,
391
+ eventSlug: SLUG,
392
+ eventTitle: "Seated show",
393
+ tickets: { [STALLS_TIER]: 2, [GA_TIER]: 1 },
394
+ ticketMeta: {
395
+ [STALLS_TIER]: {
396
+ title: "Stalls",
397
+ price: 40,
398
+ seatIds: [a1, a2],
399
+ seats: [
400
+ { id: a1, label: "Stalls · Row A · Seat 1" },
401
+ { id: a2, label: "Stalls · Row A · Seat 2" },
402
+ ],
403
+ },
404
+ [GA_TIER]: { title: "Standing", price: 20 },
405
+ },
406
+ bookingFee: null,
407
+ },
408
+ ],
409
+ }),
410
+ );
411
+ // A2 went to somebody else while the buyer was away.
412
+ seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
413
+ const { result } = await mount();
414
+
415
+ await waitFor(() => expect(result.current.selectedSeats).toEqual({ [STALLS_TIER]: [a1] }));
416
+ // The GA line came back too, so re-adding the event keeps the whole line.
417
+ expect(result.current.selectedTickets).toEqual({ [STALLS_TIER]: 1, [GA_TIER]: 1 });
418
+ expect(result.current.seatsTaken).toEqual({ labels: ["Stalls · Row A · Seat 2"] });
419
+ expect(result.current.ticketsInCart).toBe(true);
420
+ });
421
+
422
+ it("a plain ticket line in the cart restores nothing", async () => {
423
+ localStorage.setItem(
424
+ "tribenest-cart",
425
+ JSON.stringify({
426
+ items: [],
427
+ tickets: [
428
+ {
429
+ eventId: EVENT_ID,
430
+ eventSlug: SLUG,
431
+ eventTitle: "Seated show",
432
+ tickets: { [GA_TIER]: 2 },
433
+ ticketMeta: { [GA_TIER]: { title: "Standing", price: 20 } },
434
+ bookingFee: null,
435
+ },
436
+ ],
437
+ }),
438
+ );
439
+ const { result } = await mount();
440
+ expect(result.current.selectedTickets).toEqual({});
441
+ expect(result.current.ticketsInCart).toBe(true);
442
+ });
443
+ });
@@ -0,0 +1,154 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { 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 { bestAvailableSeats, indexEventSeats, useEventSeating } from "../useEventSeating";
9
+ import { buildLayout, buildSeating, seatAt, STALLS_TIER, TABLE_TIER } from "./seatingFixture";
10
+
11
+ /**
12
+ * The joined seating read (seating wave 2).
13
+ *
14
+ * The network is faked at the axios adapter, so what is asserted is the
15
+ * request that goes over the wire and the index built from what comes back:
16
+ * every seat reachable by event id and by layout id, availability counted per
17
+ * tier, and the read polled while the picker is open.
18
+ */
19
+
20
+ const PROFILE_ID = "profile-1";
21
+ const EVENT_ID = "ev-seated";
22
+
23
+ let realAdapter: AxiosAdapter | undefined;
24
+ let seatingRequests: { url: string; params: Record<string, unknown> }[] = [];
25
+ let seatingResponse: unknown;
26
+
27
+ function installAdapter() {
28
+ seatingRequests = [];
29
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
30
+ const url = config.url ?? "";
31
+ const ok = (data: unknown) => ({ data, status: 200, statusText: "OK", headers: {}, config }) as never;
32
+ if (url.includes(`/public/events/${EVENT_ID}/seating`)) {
33
+ seatingRequests.push({ url, params: (config.params ?? {}) as Record<string, unknown> });
34
+ return ok(seatingResponse);
35
+ }
36
+ return ok({});
37
+ };
38
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
39
+ axios.defaults.adapter = adapter;
40
+ }
41
+
42
+ const wrapper = ({ children }: { children: ReactNode }) => (
43
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
44
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
45
+ {children}
46
+ </ForgeClientProvider>
47
+ </QueryClientProvider>
48
+ );
49
+
50
+ beforeEach(() => {
51
+ installAdapter();
52
+ seatingResponse = buildSeating(buildLayout(), ["Stalls/A/2"]);
53
+ });
54
+
55
+ afterEach(() => {
56
+ if (realAdapter) axios.defaults.adapter = realAdapter;
57
+ });
58
+
59
+ describe("useEventSeating", () => {
60
+ it("reads the seating for the tenant and indexes it by event id and by layout id", async () => {
61
+ const { result } = renderHook(() => useEventSeating(EVENT_ID, { pollMs: 0 }), { wrapper });
62
+ await waitFor(() => expect(result.current.seating).toBeTruthy());
63
+
64
+ expect(seatingRequests[0]?.url).toContain(`/public/events/${EVENT_ID}/seating`);
65
+ expect(seatingRequests[0]?.params).toMatchObject({ profileId: PROFILE_ID });
66
+
67
+ // 8 stalls seats + 4 table seats. The GA area has no seats.
68
+ expect(result.current.seats).toHaveLength(12);
69
+ expect(result.current.byId.size).toBe(12);
70
+ expect(result.current.bySource.size).toBe(12);
71
+
72
+ const a1 = seatAt(seatingResponse as never, "Stalls", "A", "1");
73
+ const entry = result.current.byId.get(a1)!;
74
+ expect(entry.label).toBe("Stalls · Row A · Seat 1");
75
+ expect(entry.eventTicketId).toBe(STALLS_TIER);
76
+ expect(result.current.bySource.get(entry.sourceSeatId)).toBe(entry);
77
+
78
+ const tableSeat = result.current.seats.find((s) => s.eventTicketId === TABLE_TIER)!;
79
+ expect(tableSeat.label).toBe("Table 1 · Seat 1");
80
+ expect(tableSeat.rowLabel).toBe("");
81
+
82
+ // A2 is unavailable, so the stalls tier has 7 left and the table all 4.
83
+ expect(result.current.availableByTier).toEqual({ [STALLS_TIER]: 7, [TABLE_TIER]: 4 });
84
+ // The GA area maps a category to the standing tier, but puts no seat on the
85
+ // map, so that tier is NOT seated and keeps its plain quantity.
86
+ expect([...result.current.seatedTierIds].sort()).toEqual([STALLS_TIER, TABLE_TIER].sort());
87
+ });
88
+
89
+ it("polls while enabled, and requests nothing while disabled", async () => {
90
+ const polling = renderHook(() => useEventSeating(EVENT_ID, { pollMs: 30 }), { wrapper });
91
+ await waitFor(() => expect(polling.result.current.seating).toBeTruthy());
92
+ await waitFor(() => expect(seatingRequests.length).toBeGreaterThanOrEqual(3), { timeout: 3000 });
93
+ // Unmounted, so the poll below cannot be its.
94
+ polling.unmount();
95
+
96
+ installAdapter();
97
+ const disabled = renderHook(() => useEventSeating(EVENT_ID, { enabled: false, pollMs: 30 }), { wrapper });
98
+ await new Promise((resolve) => setTimeout(resolve, 120));
99
+ expect(seatingRequests).toHaveLength(0);
100
+ expect(disabled.result.current.isLoading).toBe(false);
101
+ expect(disabled.result.current.seating).toBeUndefined();
102
+ });
103
+
104
+ it("an event with no seat map reads null and indexes nothing", async () => {
105
+ seatingResponse = null;
106
+ const { result } = renderHook(() => useEventSeating(EVENT_ID, { pollMs: 0 }), { wrapper });
107
+ await waitFor(() => expect(result.current.isFetched).toBe(true));
108
+ expect(result.current.seating).toBeNull();
109
+ expect(result.current.seats).toEqual([]);
110
+ expect(result.current.availableByTier).toEqual({});
111
+ expect(result.current.seatedTierIds.size).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe("bestAvailableSeats", () => {
116
+ const seating = buildSeating(buildLayout(), ["Stalls/A/2"]);
117
+ const { seats } = indexEventSeats(seating);
118
+ const labels = (picked: { label: string }[]) => picked.map((s) => s.label);
119
+
120
+ it("picks the lowest contiguous run, skipping a row an unavailable seat breaks", () => {
121
+ // Row A holds 1, 3, 4: no run of three. Row B is the first that does.
122
+ expect(labels(bestAvailableSeats(seats, STALLS_TIER, 3))).toEqual([
123
+ "Stalls · Row B · Seat 1",
124
+ "Stalls · Row B · Seat 2",
125
+ "Stalls · Row B · Seat 3",
126
+ ]);
127
+ // A run of two exists in row A, after the gap.
128
+ expect(labels(bestAvailableSeats(seats, STALLS_TIER, 2))).toEqual(["Stalls · Row A · Seat 3", "Stalls · Row A · Seat 4"]);
129
+ });
130
+
131
+ it("falls back to the lowest seats when no row has a long enough run", () => {
132
+ expect(labels(bestAvailableSeats(seats, STALLS_TIER, 5))).toEqual([
133
+ "Stalls · Row A · Seat 1",
134
+ "Stalls · Row A · Seat 3",
135
+ "Stalls · Row A · Seat 4",
136
+ "Stalls · Row B · Seat 1",
137
+ "Stalls · Row B · Seat 2",
138
+ ]);
139
+ });
140
+
141
+ it("never offers an unavailable seat, an excluded seat, or another tier's seat", () => {
142
+ const a1 = seatAt(seating, "Stalls", "A", "1");
143
+ const picked = bestAvailableSeats(seats, STALLS_TIER, 8, new Set([a1]));
144
+ expect(picked).toHaveLength(6);
145
+ expect(picked.every((s) => s.eventTicketId === STALLS_TIER && s.status === "available" && s.id !== a1)).toBe(true);
146
+ expect(bestAvailableSeats(seats, "t-ga", 1)).toEqual([]);
147
+ });
148
+
149
+ it("adds a neighbour of a seat already held before any lower seat", () => {
150
+ const b2 = seats.find((s) => s.label === "Stalls · Row B · Seat 2")!;
151
+ const picked = bestAvailableSeats(seats, STALLS_TIER, 1, new Set([b2.id]), [b2]);
152
+ expect(labels(picked)).toEqual(["Stalls · Row B · Seat 1"]);
153
+ });
154
+ });