@tribe-nest/forge 3.53.0 → 3.54.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 (45) 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/useEvents.ts +14 -0
  7. package/src/data/queries/useMyTickets.ts +49 -0
  8. package/src/data/queries/usePaymentFlow.ts +8 -0
  9. package/src/i18n/de.json +42 -1
  10. package/src/i18n/en.json +42 -1
  11. package/src/i18n/index.ts +3 -2
  12. package/src/index.ts +22 -0
  13. package/src/provider/ForgeAppProvider.tsx +8 -1
  14. package/src/provider/ForgeProvider.tsx +3 -2
  15. package/src/provider/SiteConfigProvider.tsx +3 -2
  16. package/src/runtime/RemotePage.tsx +110 -0
  17. package/src/runtime/hostRuntime.ts +84 -0
  18. package/src/runtime/pages.spec.ts +41 -0
  19. package/src/runtime/pages.ts +183 -0
  20. package/src/runtime/pagesClient.ts +127 -0
  21. package/src/runtime/registry.spec.ts +45 -0
  22. package/src/runtime/registry.ts +102 -0
  23. package/src/server/jobs.ts +4 -1
  24. package/src/server/platformEvents.generated.ts +92 -0
  25. package/src/types/models.ts +58 -0
  26. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  27. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  28. package/src/ui/headless/event/ticketApproval.ts +109 -0
  29. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  30. package/src/ui/headless/index.ts +11 -0
  31. package/src/ui/index.ts +18 -0
  32. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  33. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  34. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  35. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  36. package/src/ui/shell/TribeNestApp.tsx +6 -0
  37. package/src/ui/styled/AccountDashboard.tsx +79 -13
  38. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  39. package/src/ui/styled/EventConfirmation.tsx +106 -9
  40. package/src/ui/styled/EventTickets.tsx +107 -13
  41. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  42. package/src/ui/styled/forge-utilities.css +310 -0
  43. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  44. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  45. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -220,6 +220,94 @@ export type PlatformEventMap = {
220
220
  unitPrice: number | null;
221
221
  }>;
222
222
  };
223
+ /**
224
+ * Event ticket request approved: The organiser approved a ticket request; the sale settled and the tickets were issued.
225
+ *
226
+ * Requires the `events.read` grant.
227
+ */
228
+ "event_ticket_request.approved": {
229
+ orderId: string;
230
+ eventId: string | null;
231
+ status: string | null;
232
+ total: number | null;
233
+ currency: string | null;
234
+ buyer: {
235
+ contactId: string | null;
236
+ email: string | null;
237
+ name: string | null;
238
+ };
239
+ tickets: Array<{
240
+ ticketId: string | null;
241
+ quantity: number;
242
+ unitPrice: number | null;
243
+ }>;
244
+ };
245
+ /**
246
+ * Event ticket request declined: The organiser declined a ticket request; the hold was released.
247
+ *
248
+ * Requires the `events.read` grant.
249
+ */
250
+ "event_ticket_request.declined": {
251
+ orderId: string;
252
+ eventId: string | null;
253
+ status: string | null;
254
+ total: number | null;
255
+ currency: string | null;
256
+ buyer: {
257
+ contactId: string | null;
258
+ email: string | null;
259
+ name: string | null;
260
+ };
261
+ tickets: Array<{
262
+ ticketId: string | null;
263
+ quantity: number;
264
+ unitPrice: number | null;
265
+ }>;
266
+ };
267
+ /**
268
+ * Event ticket request expired: A ticket request lapsed undecided; the hold and the seats were released.
269
+ *
270
+ * Requires the `events.read` grant.
271
+ */
272
+ "event_ticket_request.expired": {
273
+ orderId: string;
274
+ eventId: string | null;
275
+ status: string | null;
276
+ total: number | null;
277
+ currency: string | null;
278
+ buyer: {
279
+ contactId: string | null;
280
+ email: string | null;
281
+ name: string | null;
282
+ };
283
+ tickets: Array<{
284
+ ticketId: string | null;
285
+ quantity: number;
286
+ unitPrice: number | null;
287
+ }>;
288
+ };
289
+ /**
290
+ * Event ticket requested: Someone requested tickets to an event that needs the organiser's approval. Their card is held, not charged.
291
+ *
292
+ * Requires the `events.read` grant.
293
+ */
294
+ "event_ticket_request.submitted": {
295
+ orderId: string;
296
+ eventId: string | null;
297
+ status: string | null;
298
+ total: number | null;
299
+ currency: string | null;
300
+ buyer: {
301
+ contactId: string | null;
302
+ email: string | null;
303
+ name: string | null;
304
+ };
305
+ tickets: Array<{
306
+ ticketId: string | null;
307
+ quantity: number;
308
+ unitPrice: number | null;
309
+ }>;
310
+ };
223
311
  /**
224
312
  * Membership tier archived: A membership tier was archived and can no longer be joined.
225
313
  *
@@ -403,6 +491,10 @@ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
403
491
  "email.clicked": "emails.read",
404
492
  "email.opened": "emails.read",
405
493
  "event_ticket_order.paid": "events.read",
494
+ "event_ticket_request.approved": "events.read",
495
+ "event_ticket_request.declined": "events.read",
496
+ "event_ticket_request.expired": "events.read",
497
+ "event_ticket_request.submitted": "events.read",
406
498
  "membership_tier.archived": "membership.tier_archived",
407
499
  "membership_tier.created": "membership.tier_created",
408
500
  "message.received": "contacts.read",
@@ -66,6 +66,12 @@ export type PublicTaxQuote = {
66
66
  * Stripe always populates `paymentSecret` and never the Paystack pair; Paystack
67
67
  * always populates `accessCode` + `checkoutUrl` and never `paymentSecret`.
68
68
  */
69
+ /** Whether a started charge is taken now or only once the organiser approves. */
70
+ export type PaymentSettlement = "immediate" | "deferred";
71
+
72
+ /** How a provider holds a deferred charge until the decision. */
73
+ export type DeferredChargeStrategy = "authorization" | "saved_instrument";
74
+
69
75
  export type PaymentStartResponse = {
70
76
  /**
71
77
  * STRIPE ONLY: the PaymentIntent client secret fed to Stripe Elements.
@@ -101,6 +107,19 @@ export type PaymentStartResponse = {
101
107
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
102
108
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
103
109
  taxQuote?: PublicTaxQuote;
110
+ /**
111
+ * EVENT TICKET REQUESTS: `"deferred"` when this charge is committed now and
112
+ * settled only if the organiser approves. Absent (or `"immediate"`) on every
113
+ * ordinary sale. Drives the "Authorize" wording on the pay button.
114
+ */
115
+ settlement?: PaymentSettlement;
116
+ /**
117
+ * How the provider defers the charge. `"authorization"` places a temporary
118
+ * hold the fan can see on their statement; `"saved_instrument"` stores the
119
+ * card and takes nothing until approval. Only meaningful with
120
+ * `settlement: "deferred"`.
121
+ */
122
+ deferredStrategy?: DeferredChargeStrategy | null;
104
123
  /**
105
124
  * COHORT ENROLMENT ONLY: what THIS charge takes, when the plan is paid in
106
125
  * instalments. `totalAmount` above stays the whole plan, so a checkout that
@@ -1145,6 +1164,12 @@ export interface IEvent {
1145
1164
  createdAt: string;
1146
1165
  updatedAt: string;
1147
1166
  tickets: ITicket[];
1167
+ /**
1168
+ * At least one tier on this event needs the organiser's approval. Optional
1169
+ * on the wire; derive it from `tickets` with `eventHasApprovalTiers` rather
1170
+ * than trusting its absence.
1171
+ */
1172
+ hasApprovalTiers?: boolean;
1148
1173
  media: IMedia[];
1149
1174
  slug: string;
1150
1175
  /**
@@ -1339,6 +1364,17 @@ export type ITicket = {
1339
1364
  * {@link PublicMembershipGate}.
1340
1365
  */
1341
1366
  membershipGate?: PublicMembershipGate | null;
1367
+ /**
1368
+ * The organiser decides who gets this tier (curated access, feature 3).
1369
+ *
1370
+ * A request is lodged instead of a sale: the card is authorised, not charged,
1371
+ * the seat is held, and the organiser has a window to approve or decline.
1372
+ * Sent to every client. A site on an older Forge shows such a tier with its
1373
+ * ordinary buy button; that is accepted until the site takes the update.
1374
+ */
1375
+ requiresApproval?: boolean;
1376
+ /** How long the organiser has to decide, in hours. `null` means the platform default. */
1377
+ approvalWindowHours?: number | null;
1342
1378
  };
1343
1379
 
1344
1380
  // ---- Invoices ----------------------------------------------------------------
@@ -1506,6 +1542,12 @@ export enum OrderStatus {
1506
1542
  Delivered = "delivered",
1507
1543
  Cancelled = "cancelled",
1508
1544
  Processed = "processed",
1545
+ /** Ticket request lodged: charge committed (authorised), seat held, nothing captured, no passes. */
1546
+ PendingApproval = "pending_approval",
1547
+ /** Transient: the organiser approved and settlement is in flight. */
1548
+ Approved = "approved",
1549
+ /** The organiser declined the request; the authorisation was released. */
1550
+ Declined = "declined",
1509
1551
  }
1510
1552
 
1511
1553
  export type IPublicOrderItem = {
@@ -1620,6 +1662,22 @@ export type ITicketOrder = {
1620
1662
  refundedAmountCents?: number | string | null;
1621
1663
  refundState?: "none" | "partial" | "full" | string | null;
1622
1664
  lastRefundedAt?: string | null;
1665
+ /**
1666
+ * Ticket-request columns (curated access, feature 3). All absent on an
1667
+ * ordinary sale and on an older API build.
1668
+ *
1669
+ * `approvalRequestExpiresAt` is the "decide by" instant a fan is shown while
1670
+ * the request is `pending_approval`. `approvalFailureReason` is set when a
1671
+ * request ended without a sale: `"expired"` on a `cancelled` row whose window
1672
+ * lapsed undecided, and the provider's reason on a `payment_failed` row the
1673
+ * organiser approved but could not settle. See `getTicketOrderOutcome` and
1674
+ * `ticketRequestFailureKind`.
1675
+ */
1676
+ approvalRequestedAt?: string | null;
1677
+ approvalRequestExpiresAt?: string | null;
1678
+ approvalDecidedAt?: string | null;
1679
+ approvalDecisionNote?: string | null;
1680
+ approvalFailureReason?: string | null;
1623
1681
  customerName: string;
1624
1682
  customerEmail: string;
1625
1683
  createdAt: string;
@@ -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
+ });