@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.
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,127 @@
1
+ import React from "react";
2
+ import { useForgeT } from "../../i18n";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { usePhoneInput, type UsePhoneInputOptions, type UsePhoneInputReturn } from "../headless/forms/usePhoneInput";
5
+
6
+ export interface PhoneInputProps extends UsePhoneInputOptions {
7
+ /** Drive the field from a hook the parent already owns (checkout does this). */
8
+ controller?: UsePhoneInputReturn;
9
+ label?: string;
10
+ required?: boolean;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ }
14
+
15
+ /**
16
+ * Country select plus a national-format number field.
17
+ *
18
+ * The visitor picks a flag and types their number the way they would say it
19
+ * aloud, with no country code and no `+`. The E.164 string the rest of the
20
+ * platform needs is derived, never typed.
21
+ *
22
+ * The hint sits in the placeholder rather than under the field. A real national
23
+ * example for the selected country (`07700 900123` under GB) says more than a
24
+ * sentence would, and a permanent muted line under every phone field is exactly
25
+ * the kind of copy people stop reading. The "without the country code" wording
26
+ * appears only when someone has typed a `+` anyway, which is the moment it is
27
+ * worth saying.
28
+ */
29
+ export function PhoneInput({ controller, label, required, disabled, id, ...opts }: PhoneInputProps) {
30
+ const theme = useForgeTheme();
31
+ const t = useForgeT();
32
+ const own = usePhoneInput(opts);
33
+ const f = controller ?? own;
34
+
35
+ const fieldId = id ?? "forge-phone";
36
+ const errorId = `${fieldId}-error`;
37
+ const alpha = (a: number) =>
38
+ Math.round(a * 255)
39
+ .toString(16)
40
+ .padStart(2, "0");
41
+
42
+ const shared: React.CSSProperties = {
43
+ padding: "8px 12px",
44
+ border: `1px solid ${f.error ? "#ef4444" : `${theme.colors.primary}${alpha(0.3)}`}`,
45
+ backgroundColor: theme.colors.background,
46
+ color: theme.colors.text,
47
+ borderRadius: `${theme.cornerRadius}px`,
48
+ fontSize: 14,
49
+ };
50
+
51
+ const errorText =
52
+ f.error === "required"
53
+ ? t("forge.phone_input.error_required")
54
+ : f.error === "no_country"
55
+ ? t("forge.phone_input.error_no_country")
56
+ : f.error === "invalid"
57
+ ? t("forge.phone_input.error_invalid")
58
+ : null;
59
+
60
+ return (
61
+ <div className="fg:flex fg:flex-col fg:gap-1 fg:w-full">
62
+ {label && (
63
+ <label htmlFor={fieldId} style={{ color: theme.colors.text, fontSize: 14, fontWeight: 500 }}>
64
+ {label}
65
+ {required && <span style={{ color: "#ef4444" }}> *</span>}
66
+ </label>
67
+ )}
68
+
69
+ <div className="fg:flex fg:flex-row fg:gap-2 fg:w-full">
70
+ <select
71
+ aria-label={t("forge.phone_input.country_label")}
72
+ value={f.country ?? ""}
73
+ disabled={disabled || !f.isReady}
74
+ onChange={(e) => f.setCountry(e.target.value as never)}
75
+ style={{ ...shared, flex: "0 0 auto", maxWidth: 140, cursor: disabled ? "default" : "pointer" }}
76
+ >
77
+ {!f.country && <option value="">{t("forge.phone_input.country_placeholder")}</option>}
78
+ {f.countries.map((c) => (
79
+ <option key={c.country} value={c.country}>
80
+ {c.flag} {c.country} {c.callingCode}
81
+ </option>
82
+ ))}
83
+ </select>
84
+
85
+ <input
86
+ id={fieldId}
87
+ type="tel"
88
+ inputMode="tel"
89
+ autoComplete="tel-national"
90
+ disabled={disabled}
91
+ // Until the metadata lands there is no national example to show, so
92
+ // fall back to the generic hint rather than an empty field.
93
+ placeholder={f.placeholder || t("forge.phone_input.placeholder_fallback")}
94
+ value={f.value}
95
+ onChange={(e) => f.setValue(e.target.value)}
96
+ onBlur={f.markTouched}
97
+ aria-invalid={!!f.error}
98
+ aria-describedby={f.error ? errorId : undefined}
99
+ style={{ ...shared, flex: "1 1 auto", minWidth: 0 }}
100
+ />
101
+ </div>
102
+
103
+ {errorText && (
104
+ <p id={errorId} role="alert" style={{ color: "#ef4444", fontSize: 13 }}>
105
+ {errorText}
106
+ </p>
107
+ )}
108
+
109
+ {/* A landline can be delivered to by a courier but cannot receive a text.
110
+ Worth saying, never worth blocking on: the line type is a guess from a
111
+ numbering plan, and refusing a real number on a guess is worse than
112
+ spending one credit to find out. */}
113
+ {!errorText && f.warning === "landline" && (
114
+ <p style={{ color: theme.colors.text, opacity: 0.7, fontSize: 13 }}>
115
+ {t("forge.phone_input.warning_landline")}
116
+ </p>
117
+ )}
118
+
119
+ {/* Only once they have typed a country code anyway. */}
120
+ {!errorText && f.value.trim().startsWith("+") && (
121
+ <p style={{ color: theme.colors.text, opacity: 0.7, fontSize: 13 }}>
122
+ {t("forge.phone_input.hint_no_country_code")}
123
+ </p>
124
+ )}
125
+ </div>
126
+ );
127
+ }
@@ -192,3 +192,39 @@ describe("AccountDashboard: the way into a coaching video call", () => {
192
192
  expect(html).not.toContain("the room opens");
193
193
  });
194
194
  });
195
+
196
+ describe("AccountDashboard: the reschedule rule a session was sold under", () => {
197
+ it("shows a reschedule rule of the booking's own", () => {
198
+ const html = render([
199
+ booking({
200
+ location: null,
201
+ reschedulePolicy: {
202
+ policy: "until",
203
+ cutoffHours: 12,
204
+ followsCancellation: false,
205
+ description: "You can reschedule up to 12 hours before this starts.",
206
+ },
207
+ }),
208
+ ]);
209
+ expect(html).toContain("You can reschedule up to 12 hours before this starts.");
210
+ });
211
+
212
+ it("repeats nothing when moves follow the cancellation window", () => {
213
+ const html = render([
214
+ booking({
215
+ location: null,
216
+ reschedulePolicy: {
217
+ policy: "anytime",
218
+ cutoffHours: null,
219
+ followsCancellation: true,
220
+ description: "You can reschedule any time before this starts.",
221
+ },
222
+ }),
223
+ ]);
224
+ expect(html).not.toContain("You can reschedule");
225
+ });
226
+
227
+ it("renders a row from an older API that sends no reschedule rule", () => {
228
+ expect(() => render([booking({ location: null })])).not.toThrow();
229
+ });
230
+ });
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
+ import { CartProvider, type TicketCartItem } from "../../../contexts/CartContext";
9
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
10
+ import { Cart } from "../Cart";
11
+
12
+ /**
13
+ * The cart drawer with reserved seats in it (seating wave 2, docs §12).
14
+ *
15
+ * What only the markup can get wrong: a seated line lists its seats by name,
16
+ * offers the way back to the event's picker (the map is there, not here),
17
+ * and a plain ticket line shows neither. jsdom, because the drawer opens on
18
+ * a click and the cart hydrates from `localStorage` in an effect.
19
+ */
20
+
21
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
22
+
23
+ const seatedLine: TicketCartItem = {
24
+ eventId: "ev-seated",
25
+ eventSlug: "seated-show",
26
+ eventTitle: "Seated show",
27
+ tickets: { "t-stalls": 2 },
28
+ ticketMeta: {
29
+ "t-stalls": {
30
+ title: "Stalls",
31
+ price: 40,
32
+ seatIds: ["es-1", "es-2"],
33
+ seats: [
34
+ { id: "es-1", label: "Stalls · Row A · Seat 1" },
35
+ { id: "es-2", label: "Stalls · Row A · Seat 2" },
36
+ ],
37
+ },
38
+ },
39
+ bookingFee: null,
40
+ };
41
+
42
+ const plainLine: TicketCartItem = {
43
+ eventId: "ev-ga",
44
+ eventSlug: "standing-show",
45
+ eventTitle: "Standing show",
46
+ tickets: { "t-ga": 3 },
47
+ ticketMeta: { "t-ga": { title: "Standing", price: 20 } },
48
+ bookingFee: null,
49
+ };
50
+
51
+ let realAdapter: AxiosAdapter | undefined;
52
+
53
+ const draw = () => {
54
+ render(
55
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
56
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
57
+ <PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD", user: null } as never}>
58
+ <ForgeThemeProvider theme={theme}>
59
+ <CartProvider>
60
+ <Cart formatAmount={(n) => `$${n.toFixed(2)}`} />
61
+ </CartProvider>
62
+ </ForgeThemeProvider>
63
+ </PublicAuthContext.Provider>
64
+ </ForgeClientProvider>
65
+ </QueryClientProvider>,
66
+ );
67
+ fireEvent.click(screen.getByTestId("cart-button"));
68
+ };
69
+
70
+ beforeEach(() => {
71
+ localStorage.clear();
72
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
73
+ axios.defaults.adapter = async (config: InternalAxiosRequestConfig) =>
74
+ ({ data: {}, status: 200, statusText: "OK", headers: {}, config }) as never;
75
+ });
76
+
77
+ afterEach(() => {
78
+ cleanup();
79
+ if (realAdapter) axios.defaults.adapter = realAdapter;
80
+ localStorage.clear();
81
+ });
82
+
83
+ describe("Cart with reserved seats", () => {
84
+ it("lists a seated line's seats as chips and links back to the event to change them", async () => {
85
+ localStorage.setItem("tribenest-cart", JSON.stringify({ items: [], tickets: [seatedLine] }));
86
+ draw();
87
+
88
+ const line = await screen.findByTestId("cart-ticket-line");
89
+ expect(line.textContent).toContain("Stalls × 2");
90
+ const chips = within(line).getAllByTestId("cart-seat-chip");
91
+ expect(chips.map((chip) => chip.textContent)).toEqual(["Stalls · Row A · Seat 1", "Stalls · Row A · Seat 2"]);
92
+
93
+ const change = within(line).getByTestId("cart-change-seats");
94
+ expect(change.textContent).toContain("Change seats");
95
+ expect(change.getAttribute("href")).toBe("/i/events/seated-show");
96
+ // The line's price is the seat count times the tier price.
97
+ expect(line.textContent).toContain("$80.00");
98
+ });
99
+
100
+ it("a plain ticket line shows no chips and no change-seats link", async () => {
101
+ localStorage.setItem("tribenest-cart", JSON.stringify({ items: [], tickets: [plainLine] }));
102
+ draw();
103
+
104
+ const line = await screen.findByTestId("cart-ticket-line");
105
+ expect(line.textContent).toContain("Standing × 3");
106
+ expect(within(line).queryAllByTestId("cart-seat-chip")).toEqual([]);
107
+ expect(within(line).queryByTestId("cart-change-seats")).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,194 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
+ import type { TicketCartItem } from "../../../contexts/CartContext";
10
+ import type { useCheckout } from "../../headless/checkout/useCheckout";
11
+
12
+ /**
13
+ * The bundle checkout page when seats in the cart have been taken (seating
14
+ * wave 2, docs §12).
15
+ *
16
+ * `useCheckout` is stubbed at the module boundary: what the hook DECIDES is
17
+ * asserted in `headless/checkout/_tests/useCheckoutSeats.spec.tsx`. What is
18
+ * asserted here is the half only the markup can get wrong: the conflict is a
19
+ * visible alert naming the seats (state the buyer must act on, never behind
20
+ * a help hint), its one button prunes and navigates to the event page, and
21
+ * the summary prints a seated line's seats.
22
+ */
23
+
24
+ const state = vi.hoisted(() => ({ current: null as unknown as ReturnType<typeof useCheckout> }));
25
+
26
+ vi.mock("../../headless/checkout/useCheckout", async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import("../../headless/checkout/useCheckout")>()),
28
+ useCheckout: () => state.current,
29
+ }));
30
+
31
+ const { Checkout } = await import("../Checkout");
32
+
33
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
34
+
35
+ const seatedLine: TicketCartItem = {
36
+ eventId: "ev-seated",
37
+ eventSlug: "seated-show",
38
+ eventTitle: "Seated show",
39
+ tickets: { "t-stalls": 2 },
40
+ ticketMeta: {
41
+ "t-stalls": {
42
+ title: "Stalls",
43
+ price: 40,
44
+ seatIds: ["es-1", "es-2"],
45
+ seats: [
46
+ { id: "es-1", label: "Stalls · Row A · Seat 1" },
47
+ { id: "es-2", label: "Stalls · Row A · Seat 2" },
48
+ ],
49
+ },
50
+ },
51
+ bookingFee: null,
52
+ };
53
+
54
+ const noop = () => {};
55
+
56
+ /** The hook's answer, defaulted to a guest on the first stage with one seated line. */
57
+ const checkout = (overrides: Partial<ReturnType<typeof useCheckout>> = {}): ReturnType<typeof useCheckout> =>
58
+ ({
59
+ cartItems: [],
60
+ isCartReady: true,
61
+ subTotal: 80,
62
+ shippingCost: 0,
63
+ bookingFeeCost: 0,
64
+ discountAmount: 0,
65
+ hasPhysicalProduct: false,
66
+ isPaidCheckout: true,
67
+ chargedTotal: null,
68
+ taxQuote: null,
69
+ currentStage: "userDetails",
70
+ setCurrentStage: noop,
71
+ user: null,
72
+ guestUserData: null,
73
+ joinEmailList: false,
74
+ setJoinEmailList: noop,
75
+ submitGuest: noop,
76
+ countries: [],
77
+ selectedCountry: undefined,
78
+ shippingData: undefined,
79
+ submitAddress: async () => {},
80
+ isFetchingRates: false,
81
+ shippingGroups: [],
82
+ selections: {},
83
+ selectShippingRate: noop,
84
+ selectedShippingRates: [],
85
+ confirmRates: noop,
86
+ orderId: null,
87
+ checkoutId: null,
88
+ settledCheckoutId: null,
89
+ isBundle: true,
90
+ ticketItems: [seatedLine],
91
+ isCreatingOrder: false,
92
+ startError: "",
93
+ seatsConflict: null,
94
+ chooseOtherSeats: () => null,
95
+ hold: { isHeld: false, hasLapsed: false, label: null, expiresAt: null, secondsLeft: null },
96
+ holdExpiredError: "",
97
+ isHoldExpired: false,
98
+ retryHold: async () => {},
99
+ isRetryingHold: false,
100
+ provider: undefined,
101
+ isPaystack: false,
102
+ openPaystackCheckout: async () => {},
103
+ canOpenPaystack: false,
104
+ paymentSecret: undefined,
105
+ paymentId: undefined,
106
+ chargedAmount: 0,
107
+ chargedCurrency: "",
108
+ returnUrl: "",
109
+ isFreeCheckoutLoading: false,
110
+ freeCheckout: async () => null,
111
+ couponCode: "",
112
+ setCouponCode: noop,
113
+ showCouponInput: false,
114
+ setShowCouponInput: noop,
115
+ appliedCoupon: null,
116
+ couponError: "",
117
+ isApplyingCoupon: false,
118
+ applyCoupon: async () => {},
119
+ removeCoupon: async () => {},
120
+ isCouponEditable: true,
121
+ pageError: "",
122
+ setPageError: noop,
123
+ ...overrides,
124
+ }) as unknown as ReturnType<typeof useCheckout>;
125
+
126
+ let realAdapter: AxiosAdapter | undefined;
127
+
128
+ const draw = (value: ReturnType<typeof useCheckout>, onNavigate = vi.fn()) => {
129
+ state.current = value;
130
+ render(
131
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
132
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
133
+ <PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD", user: null } as never}>
134
+ <ForgeThemeProvider theme={theme}>
135
+ <Checkout onNavigate={onNavigate} />
136
+ </ForgeThemeProvider>
137
+ </PublicAuthContext.Provider>
138
+ </ForgeClientProvider>
139
+ </QueryClientProvider>,
140
+ );
141
+ return onNavigate;
142
+ };
143
+
144
+ beforeEach(() => {
145
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
146
+ axios.defaults.adapter = async (config: InternalAxiosRequestConfig) =>
147
+ ({ data: {}, status: 200, statusText: "OK", headers: {}, config }) as never;
148
+ });
149
+
150
+ afterEach(() => {
151
+ cleanup();
152
+ if (realAdapter) axios.defaults.adapter = realAdapter;
153
+ });
154
+
155
+ describe("Checkout: seats conflict", () => {
156
+ it("renders a visible alert naming the taken seats, whose button prunes and goes to the event page", () => {
157
+ const chooseOtherSeats = vi.fn(() => "/i/events/seated-show");
158
+ const onNavigate = draw(
159
+ checkout({
160
+ seatsConflict: {
161
+ eventId: "ev-seated",
162
+ eventSlug: "seated-show",
163
+ eventTitle: "Seated show",
164
+ seatIds: ["es-2"],
165
+ labels: ["Stalls · Row A · Seat 2"],
166
+ },
167
+ chooseOtherSeats,
168
+ }),
169
+ );
170
+
171
+ const alert = screen.getByRole("alert");
172
+ expect(alert.textContent).toContain("Some of your seats were just taken");
173
+ expect(alert.textContent).toContain("Stalls · Row A · Seat 2 for Seated show went to another buyer");
174
+
175
+ fireEvent.click(screen.getByTestId("seats-conflict-choose"));
176
+ expect(chooseOtherSeats).toHaveBeenCalledTimes(1);
177
+ expect(onNavigate).toHaveBeenCalledWith("/i/events/seated-show");
178
+ });
179
+
180
+ it("renders no alert while every seat is still available", () => {
181
+ draw(checkout());
182
+ expect(screen.queryByRole("alert")).toBeNull();
183
+ expect(screen.queryByTestId("seats-conflict")).toBeNull();
184
+ });
185
+
186
+ it("the order summary prints a seated line's seats by name", () => {
187
+ draw(checkout());
188
+ const line = screen.getByTestId("summary-ticket-line");
189
+ expect(line.textContent).toContain("Stalls");
190
+ expect(screen.getByTestId("summary-ticket-seats").textContent).toContain(
191
+ "Seats: Stalls · Row A · Seat 1, Stalls · Row A · Seat 2",
192
+ );
193
+ });
194
+ });
@@ -0,0 +1,199 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
4
+ import type { IEvent } from "../../../types/models";
5
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
6
+ import { EventSeatPicker } from "../seating/EventSeatPicker";
7
+ import { tierColorMap } from "../seating/paint";
8
+ import { indexEventSeats, type EventSeatingState } from "../../headless/event/useEventSeating";
9
+ import {
10
+ buildLayout,
11
+ buildSeating,
12
+ seatAt,
13
+ seatingEvent,
14
+ STALLS_TIER,
15
+ TABLE_TIER,
16
+ } from "../../headless/event/_tests/seatingFixture";
17
+
18
+ /**
19
+ * The buyer's seat picker (seating wave 2), rendered from a fixture document
20
+ * and a hand-built seating state, so nothing here reaches the network.
21
+ *
22
+ * What is asserted is what a buyer can see and do: every kind of object in
23
+ * the layout is drawn, a grey seat does nothing, an available seat toggles,
24
+ * the legend explains the colours, and the alert that names taken seats is
25
+ * visible rather than behind a `?`.
26
+ */
27
+
28
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
29
+ const fmt = (n: number) => `$${n.toFixed(2)}`;
30
+ const event = seatingEvent as unknown as IEvent;
31
+
32
+ const seatingState = (unavailable: string[] = [], overrides: Partial<EventSeatingState> = {}): EventSeatingState => {
33
+ const seating = buildSeating(buildLayout(), unavailable);
34
+ const index = indexEventSeats(seating);
35
+ const availableByTier: Record<string, number> = {};
36
+ const seatedTierIds = new Set<string>();
37
+ for (const seat of index.seats) {
38
+ seatedTierIds.add(seat.eventTicketId);
39
+ availableByTier[seat.eventTicketId] = (availableByTier[seat.eventTicketId] ?? 0) + (seat.status === "available" ? 1 : 0);
40
+ }
41
+ return {
42
+ seating,
43
+ isLoading: false,
44
+ isFetched: true,
45
+ refetch: async () => undefined,
46
+ byId: index.byId,
47
+ bySource: index.bySource,
48
+ availableByTier,
49
+ seatedTierIds,
50
+ seats: index.seats,
51
+ ...overrides,
52
+ };
53
+ };
54
+
55
+ const draw = (props: Partial<Parameters<typeof EventSeatPicker>[0]> = {}) => {
56
+ const onToggleSeat = vi.fn(() => ({ ok: true as const, selected: true }));
57
+ const utils = render(
58
+ <ForgeThemeProvider theme={theme}>
59
+ <EventSeatPicker
60
+ event={event}
61
+ seating={seatingState()}
62
+ selectedSeats={{}}
63
+ onToggleSeat={onToggleSeat}
64
+ formatAmount={fmt}
65
+ {...props}
66
+ />
67
+ </ForgeThemeProvider>,
68
+ );
69
+ return { ...utils, onToggleSeat };
70
+ };
71
+
72
+ const seatElement = (state: EventSeatingState, section: string, row: string, seat: string) => {
73
+ const eventSeatId = seatAt(state.seating!, section, row, seat);
74
+ const source = state.byId.get(eventSeatId)!.sourceSeatId;
75
+ const element = document.querySelector(`[data-seat-source="${source}"]`);
76
+ if (!element) throw new Error(`seat ${source} not drawn`);
77
+ return { element, eventSeatId };
78
+ };
79
+
80
+ const tap = (element: Element) => {
81
+ fireEvent.pointerDown(element, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
82
+ fireEvent.pointerUp(element, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
83
+ };
84
+
85
+ afterEach(cleanup);
86
+
87
+ describe("EventSeatPicker", () => {
88
+ it("draws every kind of object in the layout: sections, tables, GA areas, decor and text", () => {
89
+ draw();
90
+ expect(screen.getAllByTestId("seat-map-section")).toHaveLength(1);
91
+ expect(screen.getAllByTestId("seat-map-table")).toHaveLength(1);
92
+ expect(screen.getAllByTestId("seat-map-ga")).toHaveLength(1);
93
+ expect(screen.getAllByTestId("seat-map-decor")).toHaveLength(1);
94
+ expect(screen.getAllByTestId("seat-map-text")).toHaveLength(1);
95
+ // 8 stalls seats + 4 table seats, each a tappable control.
96
+ expect(screen.getAllByTestId("seat-map-seat")).toHaveLength(12);
97
+ expect(screen.getByText("Stage")).toBeTruthy();
98
+ expect(screen.getByText("Main hall")).toBeTruthy();
99
+ expect(screen.getByText("Standing floor")).toBeTruthy();
100
+ expect(screen.getByText("General admission")).toBeTruthy();
101
+ expect(screen.getByRole("application", { name: "Seat map" })).toBeTruthy();
102
+ // Zoom controls and the pan surface are there.
103
+ expect(screen.getByTestId("seat-map-zoom-in")).toBeTruthy();
104
+ expect(screen.getByTestId("seat-map-zoom-out")).toBeTruthy();
105
+ expect(screen.getByTestId("seat-map-fit")).toBeTruthy();
106
+ expect((screen.getByTestId("seat-map-canvas") as HTMLElement).style.touchAction).toBe("none");
107
+ });
108
+
109
+ it("colours seats by tier, with the same colour the legend chip carries", () => {
110
+ const state = seatingState();
111
+ draw({ seating: state });
112
+ const colors = tierColorMap(event.tickets.map((t) => t.id));
113
+ const { element } = seatElement(state, "Stalls", "A", "1");
114
+ const circle = element.querySelectorAll("circle")[1]!;
115
+ expect(circle.getAttribute("fill")).toBe(colors[STALLS_TIER]);
116
+ const { element: tableSeat } = seatElement(state, "Table 1", "", "1");
117
+ expect(tableSeat.querySelectorAll("circle")[1]!.getAttribute("fill")).toBe(colors[TABLE_TIER]);
118
+ expect(colors[STALLS_TIER]).not.toBe(colors[TABLE_TIER]);
119
+ });
120
+
121
+ it("calls toggle for a tap on an available seat, with the seat's tier and event id", () => {
122
+ const state = seatingState();
123
+ const { onToggleSeat } = draw({ seating: state });
124
+ const { element, eventSeatId } = seatElement(state, "Stalls", "B", "3");
125
+ expect(element.getAttribute("data-status")).toBe("available");
126
+ expect(element.getAttribute("aria-disabled")).toBeNull();
127
+ tap(element);
128
+ expect(onToggleSeat).toHaveBeenCalledTimes(1);
129
+ expect(onToggleSeat).toHaveBeenCalledWith(STALLS_TIER, eventSeatId);
130
+ });
131
+
132
+ it("an unavailable seat is grey, disabled, and a tap on it does nothing", () => {
133
+ const state = seatingState(["Stalls/A/2"]);
134
+ const { onToggleSeat } = draw({ seating: state });
135
+ const { element } = seatElement(state, "Stalls", "A", "2");
136
+ expect(element.getAttribute("data-status")).toBe("unavailable");
137
+ expect(element.getAttribute("aria-disabled")).toBe("true");
138
+ expect(element.getAttribute("aria-label")).toContain("unavailable");
139
+ expect(element.querySelectorAll("circle")[1]!.getAttribute("fill")).toBe("#cbd5e1");
140
+ tap(element);
141
+ expect(onToggleSeat).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it("marks a selected seat pressed and lets a tap release it", () => {
145
+ const state = seatingState();
146
+ const a1 = seatAt(state.seating!, "Stalls", "A", "1");
147
+ const { onToggleSeat } = draw({ seating: state, selectedSeats: { [STALLS_TIER]: [a1] } });
148
+ const { element } = seatElement(state, "Stalls", "A", "1");
149
+ expect(element.getAttribute("aria-pressed")).toBe("true");
150
+ expect(element.getAttribute("data-status")).toBe("selected");
151
+ tap(element);
152
+ expect(onToggleSeat).toHaveBeenCalledWith(STALLS_TIER, a1);
153
+ });
154
+
155
+ it("the legend lists each seated tier with its price and seats left, plus the two fixed states", () => {
156
+ draw({ seating: seatingState(["Stalls/A/1", "Stalls/A/2"]) });
157
+ const legend = screen.getByTestId("seat-legend");
158
+ const tiers = within(legend).getAllByTestId("seat-legend-tier");
159
+ expect(tiers).toHaveLength(2);
160
+ expect(tiers[0]!.textContent).toContain("Stalls");
161
+ expect(tiers[0]!.textContent).toContain("$40.00");
162
+ expect(tiers[0]!.textContent).toContain("6 seats left".replace(" seats", ""));
163
+ expect(tiers[1]!.textContent).toContain("Table seat");
164
+ expect(tiers[1]!.textContent).toContain("$60.00");
165
+ expect(tiers[1]!.textContent).toContain("4 left");
166
+ // The GA tier sells no seat and is not in the key.
167
+ expect(legend.textContent).not.toContain("Standing");
168
+ expect(within(legend).getByText("Selected")).toBeTruthy();
169
+ expect(within(legend).getByText("Unavailable")).toBeTruthy();
170
+ });
171
+
172
+ it("shows a brief notice when a tap is refused for the per-person cap", () => {
173
+ const state = seatingState();
174
+ const onToggleSeat = vi.fn(() => ({ ok: false as const, reason: "max" as const }));
175
+ draw({ seating: state, onToggleSeat });
176
+ tap(seatElement(state, "Stalls", "A", "4").element);
177
+ const notice = screen.getByTestId("seat-picker-notice");
178
+ expect(notice.textContent).toContain("You have the most seats allowed on Stalls.");
179
+ expect(notice.getAttribute("role")).toBe("status");
180
+ });
181
+
182
+ it("renders the taken-seats alert visibly, naming the seats, with a way to dismiss it", () => {
183
+ const onDismiss = vi.fn();
184
+ draw({ seatsTaken: { labels: ["Stalls · Row A · Seat 2", "Stalls · Row A · Seat 3"] }, onDismissSeatsTaken: onDismiss });
185
+ const alert = screen.getByRole("alert");
186
+ expect(alert.textContent).toContain("Some of your seats were just taken");
187
+ expect(alert.textContent).toContain("Stalls · Row A · Seat 2, Stalls · Row A · Seat 3");
188
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
189
+ expect(onDismiss).toHaveBeenCalledTimes(1);
190
+ });
191
+
192
+ it("shows a loading state before the read lands and an empty state when there is no map", () => {
193
+ draw({ seating: seatingState([], { seating: undefined, isLoading: true, isFetched: false, seats: [], byId: new Map(), bySource: new Map() }) });
194
+ expect(screen.getByTestId("seat-picker-loading")).toBeTruthy();
195
+ cleanup();
196
+ draw({ seating: seatingState([], { seating: null, isLoading: false, seats: [], byId: new Map(), bySource: new Map() }) });
197
+ expect(screen.getByTestId("seat-picker-empty").textContent).toContain("no seats to choose from");
198
+ });
199
+ });