@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,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
+ });
@@ -79,6 +79,24 @@ function installAdapter(tiers: MembershipTier[]) {
79
79
  if (url.includes("/public/membership-tiers")) return reply(tiers);
80
80
  if (url.includes("/public/websites/site-config")) return reply({ currency: "USD" });
81
81
  if (url.includes("/public/currencies")) return reply(currencies);
82
+ if (url.includes("/public/membership-applications")) {
83
+ return reply({
84
+ application: {
85
+ id: "app-1",
86
+ profileId: PROFILE_ID,
87
+ membershipTierId: "tier-1",
88
+ accountId: "account-1",
89
+ status: "pending",
90
+ billingCycle: null,
91
+ amount: null,
92
+ currency: null,
93
+ message: null,
94
+ closedReason: null,
95
+ },
96
+ requiresCard: false,
97
+ clientSecret: null,
98
+ });
99
+ }
82
100
  if (url.includes("/public/payments/subscriptions/free")) return reply({ membershipId: "m-1" });
83
101
  if (url.includes("/public/payments/subscriptions")) {
84
102
  return reply({ membershipId: "m-1", subscriptionId: "s-1", clientSecret: "pi_secret", requiresPayment: true });
@@ -362,3 +380,91 @@ describe("the tier grid, when no tier was chosen up front", () => {
362
380
  expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
363
381
  });
364
382
  });
383
+
384
+ describe("free trials", () => {
385
+ it("shows the trial line and Start free trial on the select step for an eligible fan", async () => {
386
+ mount({ tiers: [tier({ priceMonthly: 12, trialDays: 14, trialEligible: true })] });
387
+
388
+ const line = await screen.findByTestId("membership-checkout-trial-line");
389
+ const ends = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString("en-US", {
390
+ year: "numeric",
391
+ month: "short",
392
+ day: "numeric",
393
+ });
394
+ expect(line.textContent).toBe(`14-day free trial. Your first charge is on ${ends}.`);
395
+ expect(screen.getByRole("button", { name: "Start free trial" })).toBeTruthy();
396
+ });
397
+
398
+ it("shows neither to a fan who already had a trial", async () => {
399
+ mount({ tiers: [tier({ priceMonthly: 12, trialDays: 14, trialEligible: false })] });
400
+
401
+ await screen.findByText("Inner Circle", { exact: false });
402
+ expect(screen.queryByTestId("membership-checkout-trial-line")).toBeNull();
403
+ expect(screen.queryByRole("button", { name: "Start free trial" })).toBeNull();
404
+ });
405
+ });
406
+
407
+ describe("tier questionnaires", () => {
408
+ const questions = [
409
+ { id: "q-why", question: "Why do you want to join?", type: "textarea" },
410
+ { id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"] },
411
+ { id: "q-handle", question: "Instagram handle", type: "text", optional: true },
412
+ ];
413
+ const applicationPosts = () =>
414
+ sent.filter((r) => r.method === "post" && r.url.includes("/public/membership-applications"));
415
+
416
+ it("renders the questions, required ones marked, and no free-text message box", async () => {
417
+ mount({ tiers: [tier({ priceMonthly: 0, joinMode: "application", questionnaire: questions })] });
418
+
419
+ const why = (await screen.findByLabelText(/Why do you want to join\?/)) as HTMLTextAreaElement;
420
+ expect(why.tagName).toBe("TEXTAREA");
421
+ expect(why.required).toBe(true);
422
+ expect((screen.getByLabelText(/Which city\?/) as HTMLSelectElement).tagName).toBe("SELECT");
423
+ expect((screen.getByLabelText(/Instagram handle/) as HTMLInputElement).required).toBe(false);
424
+ expect(screen.queryByText(/Anything you'd like us to know/i)).toBeNull();
425
+ expect(screen.queryByPlaceholderText(/Tell us a little about yourself/i)).toBeNull();
426
+ });
427
+
428
+ it("refuses a blank required answer beside the questions and sends nothing", async () => {
429
+ mount({ tiers: [tier({ priceMonthly: 10, questionnaire: questions })] });
430
+
431
+ await screen.findByLabelText(/Why do you want to join\?/);
432
+ fireEvent.click(subscribeButton());
433
+
434
+ const refusal = await screen.findByTestId("membership-questionnaire-refusal");
435
+ expect(refusal.textContent).toBe("Please answer: Why do you want to join?");
436
+ expect(screen.getAllByText("This field is required")).toHaveLength(2);
437
+ expect(subscriptionPosts()).toHaveLength(0);
438
+ });
439
+
440
+ it("sends the answers with the paid subscription", async () => {
441
+ mount({ tiers: [tier({ priceMonthly: 10, questionnaire: questions })] });
442
+
443
+ fireEvent.change(await screen.findByLabelText(/Why do you want to join\?/), { target: { value: "Big fan" } });
444
+ fireEvent.change(screen.getByLabelText(/Which city\?/), { target: { value: "Berlin" } });
445
+ fireEvent.click(subscribeButton());
446
+
447
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
448
+ expect(subscriptionPosts()[0].body?.questionnaire).toEqual([
449
+ { id: "q-why", question: "Why do you want to join?", type: "textarea", answer: "Big fan" },
450
+ { id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"], answer: "Berlin" },
451
+ { id: "q-handle", question: "Instagram handle", type: "text", answer: "" },
452
+ ]);
453
+ });
454
+
455
+ it("after applying, links to the account page rather than the membership list", async () => {
456
+ mount({ tiers: [tier({ priceMonthly: 0, joinMode: "application", questionnaire: questions })] });
457
+
458
+ fireEvent.change(await screen.findByLabelText(/Why do you want to join\?/), { target: { value: "Big fan" } });
459
+ fireEvent.change(screen.getByLabelText(/Which city\?/), { target: { value: "Lagos" } });
460
+ fireEvent.click(screen.getByRole("button", { name: "Apply to join" }));
461
+
462
+ await waitFor(() => expect(applicationPosts()).toHaveLength(1));
463
+ expect(applicationPosts()[0].body).not.toHaveProperty("message");
464
+ expect(applicationPosts()[0].body?.questionnaire).toHaveLength(3);
465
+ expect(await screen.findByText("Application received")).toBeTruthy();
466
+ const link = screen.getByRole("link", { name: "Go to your account" }) as HTMLAnchorElement;
467
+ expect(link.getAttribute("href")).toBe("/i/account");
468
+ expect(screen.queryByRole("link", { name: "View memberships" })).toBeNull();
469
+ });
470
+ });