@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useEvents.ts +14 -0
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/i18n/de.json +42 -1
- package/src/i18n/en.json +42 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +92 -0
- package/src/types/models.ts +58 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
- package/src/ui/payment/ForgeStripePayment.tsx +37 -3
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +79 -13
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- 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",
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
+
});
|