@tribe-nest/forge 3.53.0 → 3.57.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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -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 +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ eventHasApprovalTiers,
4
+ nextStepAfterTicketOrder,
5
+ selectionMode,
6
+ selectionRequiresApproval,
7
+ tierBlockedByApprovalMix,
8
+ tierRequiresApproval,
9
+ } from "../ticketApproval";
10
+ import type { IEvent, ITicket } from "../../../../types/models";
11
+
12
+ /**
13
+ * Ticket requests (curated access, feature 3): the pure decisions the picker
14
+ * and the checkout make. Two rules are load-bearing and both are here:
15
+ *
16
+ * 1. ONE ORDER IS A REQUEST OR A PURCHASE, NEVER BOTH. The API refuses a
17
+ * mixed cart; the picker must stop one being built.
18
+ * 2. A FREE REQUEST HAS NO PAYMENT STEP. It comes back `pending_approval`
19
+ * with `isFreeCheckout: false`, so the old "free means isFreeCheckout"
20
+ * test alone would send it to a card form for a charge of nothing.
21
+ */
22
+ const tier = (overrides: Partial<ITicket> = {}): ITicket =>
23
+ ({
24
+ id: "t-ga",
25
+ title: "General admission",
26
+ description: "",
27
+ price: 25,
28
+ quantity: 100,
29
+ order: 0,
30
+ sold: 0,
31
+ maxPerPerson: 4,
32
+ ...overrides,
33
+ }) as ITicket;
34
+
35
+ const ga = tier();
36
+ const vip = tier({ id: "t-vip", title: "Front row", requiresApproval: true });
37
+ const backstage = tier({ id: "t-back", title: "Backstage", requiresApproval: true, price: 0 });
38
+ const tiers = [ga, vip, backstage];
39
+
40
+ describe("tierRequiresApproval / eventHasApprovalTiers", () => {
41
+ it("reads the flag, and treats its absence (an older API) as an ordinary tier", () => {
42
+ expect(tierRequiresApproval(vip)).toBe(true);
43
+ expect(tierRequiresApproval(ga)).toBe(false);
44
+ expect(tierRequiresApproval(undefined)).toBe(false);
45
+ });
46
+
47
+ it("finds an approval tier on the event even when the event-level flag did not come", () => {
48
+ expect(eventHasApprovalTiers({ tickets: [ga, vip] } as IEvent)).toBe(true);
49
+ expect(eventHasApprovalTiers({ tickets: [ga] } as IEvent)).toBe(false);
50
+ expect(eventHasApprovalTiers({ tickets: [], hasApprovalTiers: true } as unknown as IEvent)).toBe(true);
51
+ expect(eventHasApprovalTiers(undefined)).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("selectionMode / selectionRequiresApproval", () => {
56
+ it("is null while nothing is selected, so either kind is still addable", () => {
57
+ expect(selectionMode(tiers, {})).toBeNull();
58
+ expect(selectionMode(tiers, { "t-ga": 0 })).toBeNull();
59
+ expect(selectionRequiresApproval(tiers, {})).toBe(false);
60
+ });
61
+
62
+ it("is a request once an approval tier is in", () => {
63
+ expect(selectionMode(tiers, { "t-vip": 1 })).toBe("request");
64
+ expect(selectionRequiresApproval(tiers, { "t-vip": 2 })).toBe(true);
65
+ });
66
+
67
+ it("is a purchase once an ordinary tier is in", () => {
68
+ expect(selectionMode(tiers, { "t-ga": 1 })).toBe("purchase");
69
+ expect(selectionRequiresApproval(tiers, { "t-ga": 1 })).toBe(false);
70
+ });
71
+
72
+ it("reads a mixed selection as a request, the kind the server will refuse rather than silently sell", () => {
73
+ expect(selectionMode(tiers, { "t-ga": 1, "t-vip": 1 })).toBe("request");
74
+ });
75
+
76
+ it("ignores quantities for tiers the event no longer lists", () => {
77
+ expect(selectionMode(tiers, { "t-gone": 3 })).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe("tierBlockedByApprovalMix: one order is a request or a purchase", () => {
82
+ it("blocks nothing on an empty selection", () => {
83
+ expect(tierBlockedByApprovalMix(ga, tiers, {})).toBe(false);
84
+ expect(tierBlockedByApprovalMix(vip, tiers, {})).toBe(false);
85
+ });
86
+
87
+ it("blocks an ordinary tier once an approval tier is selected, and the reverse", () => {
88
+ expect(tierBlockedByApprovalMix(ga, tiers, { "t-vip": 1 })).toBe(true);
89
+ expect(tierBlockedByApprovalMix(vip, tiers, { "t-ga": 1 })).toBe(true);
90
+ });
91
+
92
+ it("lets two approval tiers share a request, and two ordinary tiers share a purchase", () => {
93
+ expect(tierBlockedByApprovalMix(backstage, tiers, { "t-vip": 1 })).toBe(false);
94
+ expect(tierBlockedByApprovalMix(ga, tiers, { "t-ga": 1 })).toBe(false);
95
+ });
96
+
97
+ it("never blocks a tier that is already in the selection, so its own stepper keeps working", () => {
98
+ // If it were blocked, a fan holding one seat could not add a second, and
99
+ // the `+` would go dark on the very tier that set the mode.
100
+ expect(tierBlockedByApprovalMix(vip, tiers, { "t-vip": 1 })).toBe(false);
101
+ expect(tierBlockedByApprovalMix(ga, tiers, { "t-ga": 2 })).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("nextStepAfterTicketOrder: where the checkout goes once the order exists", () => {
106
+ it("completes a free purchase, exactly as before", () => {
107
+ expect(nextStepAfterTicketOrder({ isFreeCheckout: true, totalAmount: 0 })).toBe("complete");
108
+ });
109
+
110
+ it("REGRESSION: completes a FREE REQUEST, which is not a free checkout", () => {
111
+ // The server has already put it in `pending_approval` and minted nothing,
112
+ // so `isFreeCheckout` is false. There is no card to show.
113
+ expect(nextStepAfterTicketOrder({ isFreeCheckout: false, awaitingApproval: true, totalAmount: 0 })).toBe(
114
+ "complete",
115
+ );
116
+ // A bigint-ish string total, which is what some responses carry.
117
+ expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: "0" })).toBe("complete");
118
+ expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: null })).toBe("complete");
119
+ });
120
+
121
+ it("sends a PRICED request to payment, like any sale", () => {
122
+ // The card is authorised rather than charged, but the fan still has to
123
+ // present it; only start-payment's wording differs.
124
+ expect(nextStepAfterTicketOrder({ isFreeCheckout: false, awaitingApproval: true, totalAmount: 40 })).toBe(
125
+ "payment",
126
+ );
127
+ expect(nextStepAfterTicketOrder({ awaitingApproval: true, totalAmount: "12.50" })).toBe("payment");
128
+ });
129
+
130
+ it("sends an ordinary priced sale to payment, and never completes on a zero total alone", () => {
131
+ expect(nextStepAfterTicketOrder({ isFreeCheckout: false, totalAmount: 40 })).toBe("payment");
132
+ // A zero total without `isFreeCheckout` and without `awaitingApproval` is
133
+ // the old behaviour: the server did not say it was free, so start payment.
134
+ expect(nextStepAfterTicketOrder({ isFreeCheckout: false, totalAmount: 0 })).toBe("payment");
135
+ expect(nextStepAfterTicketOrder({})).toBe("payment");
136
+ });
137
+ });
@@ -0,0 +1,250 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach, vi } 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
+
12
+ /**
13
+ * Ticket requests (curated access, feature 3), end to end through the REAL
14
+ * hook with only the network faked.
15
+ *
16
+ * The case that matters most is the free request. The server answers it with
17
+ * `isFreeCheckout: false` (no passes were minted: the order is
18
+ * `pending_approval`) and `awaitingApproval: true`, so the branch that used to
19
+ * decide "free means done" would have sent the fan to a card form for a charge
20
+ * of nothing. The hook has to hand the order id to `onComplete` exactly as it
21
+ * does for a free purchase, and never call start-payment.
22
+ *
23
+ * Providers are real and the axios adapter is swapped, so the assertion is on
24
+ * what actually went over the wire.
25
+ */
26
+
27
+ const PROFILE_ID = "profile-1";
28
+ const SLUG = "summer-show";
29
+
30
+ const event = {
31
+ id: "ev-1",
32
+ slug: SLUG,
33
+ title: "Summer show",
34
+ dateTime: "2026-12-01T19:00:00.000Z",
35
+ type: "in_person",
36
+ status: "published",
37
+ tickets: [
38
+ { id: "t-ga", title: "General admission", description: "", price: 25, quantity: 100, order: 0, sold: 0, maxPerPerson: 4 },
39
+ {
40
+ id: "t-vip",
41
+ title: "Front row",
42
+ description: "",
43
+ price: 0,
44
+ quantity: 10,
45
+ order: 1,
46
+ sold: 0,
47
+ maxPerPerson: 2,
48
+ requiresApproval: true,
49
+ approvalWindowHours: 72,
50
+ },
51
+ {
52
+ id: "t-priced",
53
+ title: "Backstage",
54
+ description: "",
55
+ price: 40,
56
+ quantity: 10,
57
+ order: 2,
58
+ sold: 0,
59
+ maxPerPerson: 2,
60
+ requiresApproval: true,
61
+ approvalWindowHours: null,
62
+ },
63
+ ],
64
+ media: [],
65
+ questionnaire: [],
66
+ bookingFee: null,
67
+ };
68
+
69
+ type Sent = { method: string; url: string; body: Record<string, unknown> };
70
+ let sent: Sent[] = [];
71
+ let realAdapter: AxiosAdapter | undefined;
72
+ let orderResponse: Record<string, unknown> = {};
73
+
74
+ function installAdapter() {
75
+ sent = [];
76
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
77
+ const url = config.url ?? "";
78
+ const body = typeof config.data === "string" ? (JSON.parse(config.data) as Record<string, unknown>) : {};
79
+ sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
80
+ const ok = (data: unknown) => ({ data, status: 200, statusText: "OK", headers: {}, config }) as never;
81
+
82
+ if (url.includes(`/public/events/${SLUG}`)) return ok(event);
83
+ if (url.includes(`/public/events/${event.id}/orders`)) return ok(orderResponse);
84
+ if (url.includes(`/public/events/${event.id}/start-payment`)) {
85
+ return ok({
86
+ paymentSecret: "pi_secret",
87
+ paymentId: "pi_1",
88
+ paymentProvider: "stripe",
89
+ totalAmount: 40,
90
+ settlement: "deferred",
91
+ deferredStrategy: "authorization",
92
+ });
93
+ }
94
+ if (url.includes("/public/currencies")) {
95
+ return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
96
+ }
97
+ return ok({});
98
+ };
99
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
100
+ axios.defaults.adapter = adapter;
101
+ }
102
+
103
+ const wrapper = ({ children }: { children: ReactNode }) => (
104
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}>
105
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
106
+ <PublicAuthProvider>
107
+ <CartProvider>{children}</CartProvider>
108
+ </PublicAuthProvider>
109
+ </ForgeClientProvider>
110
+ </QueryClientProvider>
111
+ );
112
+
113
+ const mount = (onComplete?: (info: { slug: string; orderId: string }) => void) =>
114
+ renderHook(() => useEventCheckout(SLUG, { onComplete }), { wrapper });
115
+
116
+ const fillDetails = (result: { current: ReturnType<typeof useEventCheckout> }) =>
117
+ act(() => {
118
+ result.current.setFirstName("Fan");
119
+ result.current.setLastName("Person");
120
+ result.current.setEmail("fan@example.com");
121
+ });
122
+
123
+ beforeEach(() => {
124
+ localStorage.clear();
125
+ installAdapter();
126
+ });
127
+
128
+ afterEach(() => {
129
+ if (realAdapter) axios.defaults.adapter = realAdapter;
130
+ localStorage.clear();
131
+ });
132
+
133
+ describe("useEventCheckout: a ticket request", () => {
134
+ it("REGRESSION: a FREE request completes with the order id and never starts a payment", async () => {
135
+ orderResponse = {
136
+ orderId: "order-free-request",
137
+ isFreeCheckout: false,
138
+ awaitingApproval: true,
139
+ totalAmount: 0,
140
+ subTotal: 0,
141
+ discountAmount: 0,
142
+ feeAmount: 0,
143
+ requestExpiresAt: "2026-09-19T12:00:00.000Z",
144
+ holdExpiresAt: null,
145
+ };
146
+ const onComplete = vi.fn();
147
+ const { result } = mount(onComplete);
148
+ await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
149
+
150
+ act(() => result.current.setTicketQty("t-vip", 1));
151
+ expect(result.current.awaitingApproval).toBe(true);
152
+ expect(result.current.hasApprovalTiers).toBe(true);
153
+ await fillDetails(result);
154
+
155
+ await act(async () => {
156
+ await result.current.continueToPayment();
157
+ });
158
+
159
+ expect(onComplete).toHaveBeenCalledTimes(1);
160
+ expect(onComplete).toHaveBeenCalledWith({ slug: SLUG, orderId: "order-free-request" });
161
+ // The order was posted, the charge was not.
162
+ expect(sent.some((r) => r.method === "post" && r.url.includes("/orders"))).toBe(true);
163
+ expect(sent.some((r) => r.url.includes("/start-payment"))).toBe(false);
164
+ // Still on details: no payment step was entered for a charge of nothing.
165
+ expect(result.current.step).not.toBe("payment");
166
+ expect(result.current.requestExpiresAt).toBe("2026-09-19T12:00:00.000Z");
167
+ });
168
+
169
+ it("a PRICED request still goes to payment, and carries the deferred settlement through", async () => {
170
+ orderResponse = {
171
+ orderId: "order-priced-request",
172
+ isFreeCheckout: false,
173
+ awaitingApproval: true,
174
+ totalAmount: 40,
175
+ subTotal: 40,
176
+ discountAmount: 0,
177
+ feeAmount: 0,
178
+ requestExpiresAt: "2026-09-19T12:00:00.000Z",
179
+ holdExpiresAt: "2026-09-16T12:15:00.000Z",
180
+ };
181
+ const onComplete = vi.fn();
182
+ const { result } = mount(onComplete);
183
+ await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
184
+
185
+ act(() => result.current.setTicketQty("t-priced", 1));
186
+ await fillDetails(result);
187
+ await act(async () => {
188
+ await result.current.continueToPayment();
189
+ });
190
+
191
+ expect(onComplete).not.toHaveBeenCalled();
192
+ const start = sent.find((r) => r.url.includes("/start-payment"));
193
+ expect(start).toBeDefined();
194
+ expect(start?.body).toMatchObject({ orderId: "order-priced-request", profileId: PROFILE_ID });
195
+ await waitFor(() => expect(result.current.step).toBe("payment"));
196
+ expect(result.current.clientSecret).toBe("pi_secret");
197
+ // What the payment renderer reads to say "Authorize" and "a hold will show".
198
+ expect(result.current.settlement).toBe("deferred");
199
+ expect(result.current.deferredStrategy).toBe("authorization");
200
+ });
201
+
202
+ it("an ordinary free purchase completes exactly as it always did", async () => {
203
+ orderResponse = { orderId: "order-free", isFreeCheckout: true, totalAmount: 0, feeAmount: 0, holdExpiresAt: null };
204
+ const onComplete = vi.fn();
205
+ const { result } = mount(onComplete);
206
+ await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
207
+
208
+ act(() => result.current.setTicketQty("t-ga", 1));
209
+ expect(result.current.awaitingApproval).toBe(false);
210
+ await fillDetails(result);
211
+ await act(async () => {
212
+ await result.current.continueToPayment();
213
+ });
214
+
215
+ expect(onComplete).toHaveBeenCalledWith({ slug: SLUG, orderId: "order-free" });
216
+ expect(sent.some((r) => r.url.includes("/start-payment"))).toBe(false);
217
+ });
218
+
219
+ it("refuses to mix a request and a purchase in one selection", async () => {
220
+ const { result } = mount();
221
+ await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
222
+
223
+ act(() => result.current.setTicketQty("t-vip", 1));
224
+ expect(result.current.isTierBlockedByApprovalMix("t-ga")).toBe(true);
225
+ expect(result.current.isTierBlockedByApprovalMix("t-priced")).toBe(false);
226
+ // A host driving the hook from its own UI cannot build the cart either.
227
+ act(() => result.current.setTicketQty("t-ga", 1));
228
+ expect(result.current.selectedTickets).toEqual({ "t-vip": 1 });
229
+
230
+ // Clearing the request frees the ordinary tier again.
231
+ act(() => result.current.setTicketQty("t-vip", 0));
232
+ expect(result.current.isTierBlockedByApprovalMix("t-ga")).toBe(false);
233
+ act(() => result.current.setTicketQty("t-ga", 2));
234
+ expect(result.current.selectedTickets).toEqual({ "t-ga": 2 });
235
+ expect(result.current.isTierBlockedByApprovalMix("t-vip")).toBe(true);
236
+ });
237
+
238
+ it("does not send a request to the cart", async () => {
239
+ const { result } = mount();
240
+ await waitFor(() => expect(result.current.event?.id).toBe("ev-1"));
241
+
242
+ act(() => result.current.setTicketQty("t-vip", 1));
243
+ let added: boolean | undefined;
244
+ act(() => {
245
+ added = result.current.addTicketsToCart();
246
+ });
247
+ expect(added).toBe(false);
248
+ expect(result.current.ticketsInCart).toBe(false);
249
+ });
250
+ });
@@ -0,0 +1,109 @@
1
+ import type { IEvent, ITicket } from "../../../types/models";
2
+
3
+ /**
4
+ * Ticket requests (curated access, feature 3): the pure decisions behind the
5
+ * picker and the checkout, kept out of the hook so they can be tested without
6
+ * standing up the providers the hook needs.
7
+ *
8
+ * A tier with `requiresApproval` is not sold, it is REQUESTED: the card is
9
+ * committed (authorised) and the seat held, and the organiser has a window to
10
+ * approve or decline. One order is either a request or a purchase, never
11
+ * both: the API refuses a mixed cart (`errors.event.mixed_approval_cart`), and
12
+ * the picker stops the fan building one in the first place.
13
+ */
14
+
15
+ /** Does this tier need the organiser's approval? Absent on an older API build means no. */
16
+ export function tierRequiresApproval(ticket: Pick<ITicket, "requiresApproval"> | undefined): boolean {
17
+ return ticket?.requiresApproval === true;
18
+ }
19
+
20
+ /** Does this event carry any approval-required tier at all? Read off the tiers, not the flag, so a partial payload still answers. */
21
+ export function eventHasApprovalTiers(event: Pick<IEvent, "tickets" | "hasApprovalTiers"> | undefined): boolean {
22
+ if (!event) return false;
23
+ if (event.hasApprovalTiers === true) return true;
24
+ return (event.tickets ?? []).some(tierRequiresApproval);
25
+ }
26
+
27
+ /**
28
+ * Which kind of order the current selection would make. `null` while nothing
29
+ * is selected (either kind is still addable), `"request"` once an approval
30
+ * tier is in, `"purchase"` once an ordinary one is. A mixed selection cannot
31
+ * arise through `tierBlockedByApprovalMix`, but if one is handed in (a stale
32
+ * cache, a tier that changed mode underneath the fan) it reads as a request,
33
+ * because that is the button the server will refuse rather than the one it
34
+ * will silently sell.
35
+ */
36
+ export function selectionMode(
37
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
38
+ quantities: Record<string, number>,
39
+ ): "request" | "purchase" | null {
40
+ let approval = false;
41
+ let ordinary = false;
42
+ for (const ticket of tickets) {
43
+ if ((quantities[ticket.id] ?? 0) <= 0) continue;
44
+ if (tierRequiresApproval(ticket)) approval = true;
45
+ else ordinary = true;
46
+ }
47
+ if (approval) return "request";
48
+ if (ordinary) return "purchase";
49
+ return null;
50
+ }
51
+
52
+ /** The selection contains at least one approval tier, so submitting it lodges a request. */
53
+ export function selectionRequiresApproval(
54
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
55
+ quantities: Record<string, number>,
56
+ ): boolean {
57
+ return selectionMode(tickets, quantities) === "request";
58
+ }
59
+
60
+ /**
61
+ * May this tier be added to the current selection?
62
+ *
63
+ * Blocked when the selection already holds the OTHER kind: an approval tier
64
+ * beside a purchase, or a purchase beside a request. A tier that is already in
65
+ * the selection is never blocked (it is the kind that set the mode), and an
66
+ * empty selection blocks nothing.
67
+ */
68
+ export function tierBlockedByApprovalMix(
69
+ ticket: Pick<ITicket, "id" | "requiresApproval">,
70
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
71
+ quantities: Record<string, number>,
72
+ ): boolean {
73
+ if ((quantities[ticket.id] ?? 0) > 0) return false;
74
+ const mode = selectionMode(tickets, quantities);
75
+ if (mode === null) return false;
76
+ return tierRequiresApproval(ticket) ? mode === "purchase" : mode === "request";
77
+ }
78
+
79
+ /** The fields of a `POST /public/events/:id/orders` response the routing reads. */
80
+ export interface TicketOrderRoutingInput {
81
+ isFreeCheckout?: boolean;
82
+ awaitingApproval?: boolean;
83
+ totalAmount?: number | string | null;
84
+ }
85
+
86
+ /**
87
+ * Where the checkout goes once the order row exists.
88
+ *
89
+ * complete nothing to pay: a free purchase (passes minted, `isFreeCheckout`)
90
+ * or a FREE REQUEST (already `pending_approval`, `isFreeCheckout`
91
+ * false, `awaitingApproval` true, total 0). Both hand the order id
92
+ * to `onComplete`, and the host navigates to the finalise page,
93
+ * which renders the right outcome off the order's own status.
94
+ * payment there is money on it: start the charge. A priced request runs
95
+ * the payment step exactly as a sale does; only the wording and
96
+ * the settlement differ, and start-payment says which.
97
+ *
98
+ * `isFreeCheckout` wins outright, as it always has. The free-request branch is
99
+ * keyed on the TOTAL, not on `awaitingApproval` alone, because a priced
100
+ * request also carries `awaitingApproval: true` and must still reach the card.
101
+ */
102
+ export function nextStepAfterTicketOrder(order: TicketOrderRoutingInput): "complete" | "payment" {
103
+ if (order.isFreeCheckout) return "complete";
104
+ if (order.awaitingApproval === true) {
105
+ const total = Number(order.totalAmount ?? 0);
106
+ if (!Number.isFinite(total) || total <= 0) return "complete";
107
+ }
108
+ return "payment";
109
+ }
@@ -22,6 +22,12 @@ import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals }
22
22
  import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
23
23
  import { readAttributionRef } from "../../../utils/attribution";
24
24
  import { readLanding } from "../../../utils/landing";
25
+ import {
26
+ eventHasApprovalTiers,
27
+ nextStepAfterTicketOrder,
28
+ selectionRequiresApproval,
29
+ tierBlockedByApprovalMix,
30
+ } from "./ticketApproval";
25
31
 
26
32
  export type EventCheckoutStep = "tickets" | "details" | "payment";
27
33
 
@@ -32,7 +38,10 @@ export interface UseEventCheckoutOptions {
32
38
  finalisePath?: (slug: string, orderId: string) => string;
33
39
  /** Called when a FREE order completes in-app — the host navigates however it
34
40
  * wants (e.g. its router), instead of Forge doing a `window.location` redirect.
35
- * Omit to keep the default redirect. */
41
+ * Omit to keep the default redirect. Also fired for a FREE TICKET REQUEST
42
+ * (`awaitingApproval` with nothing to pay), which has no payment step either:
43
+ * the finalise page it lands on reads the order's status and renders
44
+ * "request received". */
36
45
  onComplete?: (info: { slug: string; orderId: string }) => void;
37
46
  }
38
47
 
@@ -179,6 +188,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
179
188
  [selectedTickets],
180
189
  );
181
190
 
191
+ /**
192
+ * Ticket requests (curated access, feature 3).
193
+ *
194
+ * `hasApprovalTiers` says the event carries one at all; `awaitingApproval`
195
+ * says THIS selection is a request rather than a purchase, which is what the
196
+ * primary button ("Request tickets") and the payment wording read. One order
197
+ * is either, never both: `isTierBlockedByApprovalMix` is what the picker
198
+ * disables the `+` on, so the server's mixed-cart refusal is never reached.
199
+ */
200
+ const hasApprovalTiers = eventHasApprovalTiers(event);
201
+ const awaitingApproval = useMemo(
202
+ () => selectionRequiresApproval(event?.tickets ?? [], selectedTickets),
203
+ [event, selectedTickets],
204
+ );
205
+ const isTierBlockedByApprovalMix = (ticketId: string) => {
206
+ const ticket = event?.tickets.find((t) => t.id === ticketId);
207
+ return ticket ? tierBlockedByApprovalMix(ticket, event?.tickets ?? [], selectedTickets) : false;
208
+ };
209
+ /** When the request lapses if the organiser does not decide, from the order response. */
210
+ const [requestExpiresAt, setRequestExpiresAt] = useState<string | null>(null);
211
+
182
212
  /**
183
213
  * The artist's booking fee (1.4) — the thing the buyer was charged and never
184
214
  * told about, on any surface, on either rendering stack.
@@ -212,6 +242,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
212
242
  // rejects those with "quantity must be positive").
213
243
  const setTicketQty = (ticketId: string, qty: number) =>
214
244
  setSelectedTickets((prev) => {
245
+ // A request and a purchase cannot share an order. The picker disables
246
+ // the control, and this refuses the write too, so a host that drives the
247
+ // hook from its own UI cannot build the cart the server will refuse.
248
+ if (qty > 0 && (prev[ticketId] ?? 0) === 0 && event) {
249
+ const ticket = event.tickets.find((t) => t.id === ticketId);
250
+ if (ticket && tierBlockedByApprovalMix(ticket, event.tickets, prev)) return prev;
251
+ }
215
252
  const next = { ...prev };
216
253
  if (qty > 0) next[ticketId] = qty;
217
254
  else delete next[ticketId];
@@ -253,6 +290,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
253
290
  return false;
254
291
  }
255
292
  if (!event) return false;
293
+ // A request is not a purchase and cannot ride in a bundle: the bundle
294
+ // checkout forces payment and the API refuses approval tiers in one. Sent
295
+ // to the cart it would fail at the end of a longer journey, so it is
296
+ // refused here, and the surface hides the exit for a request anyway.
297
+ if (awaitingApproval) {
298
+ setError("Tickets that need our approval are requested on their own.");
299
+ return false;
300
+ }
256
301
  setError(null);
257
302
  setTickets({
258
303
  eventId: event.id,
@@ -372,12 +417,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
372
417
  // legitimately reports 0 — falling back to the estimate there would put a
373
418
  // fee on a comp.
374
419
  setServerFeeAmount(data.feeAmount ?? 0);
420
+ setRequestExpiresAt(data.requestExpiresAt ?? null);
375
421
  const origin = typeof window !== "undefined" ? window.location.origin : "";
376
422
  const ru = `${origin}${finalisePath(slug, data.orderId)}`;
377
423
  setReturnUrl(ru);
378
- if (data.isFreeCheckout) {
379
- // Free order — no payment. Hand navigation to the host if it asked, else
380
- // default to an in-app redirect to the finalise page.
424
+ if (nextStepAfterTicketOrder(data) === "complete") {
425
+ // Nothing to pay: a free order, or a free ticket REQUEST that is already
426
+ // `pending_approval`. Hand navigation to the host if it asked, else
427
+ // default to an in-app redirect to the finalise page, which renders the
428
+ // right outcome off the order's own status.
381
429
  if (opts.onComplete) opts.onComplete({ slug, orderId: data.orderId });
382
430
  else if (typeof window !== "undefined") window.location.href = ru;
383
431
  return;
@@ -436,6 +484,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
436
484
  // subtotal, so keeping it would understate the fee on the fresh, full-price
437
485
  // order the next `continueToPayment` creates.
438
486
  setServerFeeAmount(null);
487
+ setRequestExpiresAt(null);
439
488
  setStep("details");
440
489
  };
441
490
 
@@ -493,6 +542,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
493
542
  /** What each selected PWYW tier will actually be charged, per unit. */
494
543
  effectiveAmounts,
495
544
  ticketCount,
545
+ /**
546
+ * Ticket requests (curated access, feature 3). `hasApprovalTiers`: the
547
+ * event carries at least one tier the organiser approves. `awaitingApproval`:
548
+ * THIS selection is a request, so the primary button reads "Request
549
+ * tickets", the pay button "Authorize", and nothing is sold until the
550
+ * organiser decides. `isTierBlockedByApprovalMix(id)`: the tier cannot join
551
+ * the current selection because one order is either a request or a
552
+ * purchase, never both. `requestExpiresAt`: when an undecided request
553
+ * lapses, once the order exists.
554
+ */
555
+ hasApprovalTiers,
556
+ awaitingApproval,
557
+ isTierBlockedByApprovalMix,
558
+ requestExpiresAt,
559
+ /**
560
+ * From start-payment: `"deferred"` on a priced request, so the payment
561
+ * renderer can label the button "Authorize" and say the card is charged
562
+ * only on approval. Undefined on a sale.
563
+ */
564
+ settlement: flow.settlement,
565
+ deferredStrategy: flow.deferredStrategy,
496
566
  // The server's figure wins the moment there is one: start-payment first,
497
567
  // then the order response (which is already net of any discount), and only
498
568
  // the locally summed ticket prices before either exists.
@@ -53,6 +53,17 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
53
53
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
54
54
  // each composed from the data hooks so a creator can rebuild any page.
55
55
  export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
56
+ // Ticket requests (curated access): the pure decisions behind the picker and
57
+ // the post-order routing, for a host that drives the hook from its own UI.
58
+ export {
59
+ eventHasApprovalTiers,
60
+ nextStepAfterTicketOrder,
61
+ selectionMode,
62
+ selectionRequiresApproval,
63
+ tierBlockedByApprovalMix,
64
+ tierRequiresApproval,
65
+ type TicketOrderRoutingInput,
66
+ } from "./event/ticketApproval";
56
67
  export {
57
68
  usePresaleCode,
58
69
  type PresaleCodeField,
@@ -89,6 +100,29 @@ export {
89
100
  type MembershipCheckoutStep,
90
101
  type BillingCycle,
91
102
  } from "./membership/useMembershipCheckout";
103
+ // Curated access (invite-only and apply-to-join tiers): the pure decisions the
104
+ // tier card, the checkout and the account page share.
105
+ export {
106
+ canWithdrawMembershipApplication,
107
+ MEMBERSHIP_APPLICATION_MESSAGE_MAX,
108
+ membershipApplicationOutcome,
109
+ tierCardAction,
110
+ tierUsesApplication,
111
+ type MembershipApplicationOutcome,
112
+ type MembershipTierCardAction,
113
+ } from "./membership/membershipApplication";
114
+ export {
115
+ formatTrialDate,
116
+ isMembershipInTrial,
117
+ tierTrialDays,
118
+ trialEndDate,
119
+ } from "./membership/membershipTrial";
120
+ // Tier questionnaires: which questions a checkout asks and what it sends.
121
+ export {
122
+ buildQuestionnaireAnswers,
123
+ firstMissingAnswer,
124
+ tierQuestionnaire,
125
+ } from "./membership/membershipQuestionnaire";
92
126
  export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
93
127
  export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
94
128
  export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";