@tribe-nest/forge 3.22.0 → 3.24.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 +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/useAnalytics.ts +59 -8
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +16 -0
- package/src/data/queries/useMusicLink.ts +28 -0
- package/src/data/queries/useMyBookings.ts +25 -0
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +6 -0
- package/src/server/index.ts +51 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +94 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
- package/src/ui/format/_tests/attendees.spec.ts +4 -4
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +2 -2
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/useCheckout.ts +72 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +70 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +14 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +23 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +73 -4
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +25 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +3 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +1 -1
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +135 -21
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +2 -2
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MusicLinkPage.tsx +365 -0
- package/src/ui/styled/OfferButton.tsx +1 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +1 -1
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +3 -3
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -119,9 +119,9 @@ describe("attendee helpers", () => {
|
|
|
119
119
|
it("labels each seat with its tier, and numbers them only when there is more than one", () => {
|
|
120
120
|
expect(slots({ quantities: { vip: 1 } })[0].label).toBe("VIP");
|
|
121
121
|
expect(slots({ quantities: { ga: 3 } }).map((s) => s.label)).toEqual([
|
|
122
|
-
"General Admission
|
|
123
|
-
"General Admission
|
|
124
|
-
"General Admission
|
|
122
|
+
"General Admission · Attendee 1 of 3",
|
|
123
|
+
"General Admission · Attendee 2 of 3",
|
|
124
|
+
"General Admission · Attendee 3 of 3",
|
|
125
125
|
]);
|
|
126
126
|
});
|
|
127
127
|
|
|
@@ -172,7 +172,7 @@ describe("attendee helpers", () => {
|
|
|
172
172
|
if (result.ok) return;
|
|
173
173
|
expect(result.reason).toBe("name_required");
|
|
174
174
|
expect(result.slot.index).toBe(1);
|
|
175
|
-
expect(result.message).toContain("General Admission
|
|
175
|
+
expect(result.message).toContain("General Admission · Attendee 2 of 2");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
it("refuses whitespace-only, which would otherwise write an empty owner_name", () => {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { ITicket } from "../../../types/models";
|
|
3
|
+
import {
|
|
4
|
+
formatOnSaleAt,
|
|
5
|
+
isTicketScheduled,
|
|
6
|
+
maxAddableTickets,
|
|
7
|
+
onSaleBadge,
|
|
8
|
+
onSaleNotice,
|
|
9
|
+
ticketSaleWindow,
|
|
10
|
+
ticketSeatLabel,
|
|
11
|
+
ticketSeatNotice,
|
|
12
|
+
ticketSeatState,
|
|
13
|
+
ticketSeatsAvailable,
|
|
14
|
+
ticketSeatsUnsold,
|
|
15
|
+
} from "../ticketAvailability";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The availability arithmetic shared by BOTH storefront stacks (Forge and the
|
|
19
|
+
* legacy `apps/client`). It is shared rather than written twice precisely so the
|
|
20
|
+
* two cannot drift from each other or from the server — these tests pin the
|
|
21
|
+
* behaviour they both depend on.
|
|
22
|
+
*
|
|
23
|
+
* The two numbers that must match the server exactly:
|
|
24
|
+
*
|
|
25
|
+
* - `quantity - sold - held`, the predicate in `inventoryHold.ts`, and
|
|
26
|
+
* - `now < availableFrom`, the `not_yet_on_sale` branch of
|
|
27
|
+
* `evaluateTierSellability`.
|
|
28
|
+
*
|
|
29
|
+
* Every case below is written as the buyer's screen versus the server's answer,
|
|
30
|
+
* because a disagreement between them is the whole defect class.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const tier = (over: Partial<ITicket> = {}): ITicket => ({
|
|
34
|
+
id: over.id ?? "t1",
|
|
35
|
+
title: "GA",
|
|
36
|
+
description: "",
|
|
37
|
+
price: 20,
|
|
38
|
+
quantity: 100,
|
|
39
|
+
order: 0,
|
|
40
|
+
sold: 0,
|
|
41
|
+
maxPerPerson: 10,
|
|
42
|
+
...over,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** The predicate `services/_core/inventoryHold.ts` actually runs. */
|
|
46
|
+
const serverWouldSell = (t: ITicket, n: number) => t.quantity - t.sold - (t.held ?? 0) >= n;
|
|
47
|
+
|
|
48
|
+
describe("ticketSeatsAvailable", () => {
|
|
49
|
+
it("subtracts held, which is what the server sells on", () => {
|
|
50
|
+
expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 5, held: 3 }))).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("treats an ABSENT held as zero, so an older API build is unchanged", () => {
|
|
54
|
+
// The column is published by `PUBLIC_TICKET_COLUMNS`, but a site pinned to
|
|
55
|
+
// a build that predates the hold engine must keep its old behaviour rather
|
|
56
|
+
// than read every tier as sold out.
|
|
57
|
+
expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 5 }))).toBe(5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never goes negative, so an oversold tier reads as zero not as a negative cap", () => {
|
|
61
|
+
expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 9, held: 4 }))).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("is exactly the server's predicate — the bug, stated as an assertion", () => {
|
|
65
|
+
// The reported case: the storefront said "3 left", the server said no.
|
|
66
|
+
const rush = tier({ quantity: 10, sold: 7, held: 3 });
|
|
67
|
+
expect(rush.quantity - rush.sold).toBe(3); // what the old UI showed
|
|
68
|
+
expect(ticketSeatsAvailable(rush)).toBe(0); // what the server means
|
|
69
|
+
expect(serverWouldSell(rush, 1)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("agrees with the server at every quantity across the boundary", () => {
|
|
73
|
+
const t = tier({ quantity: 10, sold: 6, held: 2 });
|
|
74
|
+
for (let n = 1; n <= 5; n += 1) {
|
|
75
|
+
expect(n <= ticketSeatsAvailable(t)).toBe(serverWouldSell(t, n));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("ticketSeatsUnsold", () => {
|
|
81
|
+
it("ignores held — it is the waitlist's question, not the buy button's", () => {
|
|
82
|
+
expect(ticketSeatsUnsold(tier({ quantity: 10, sold: 7, held: 3 }))).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("ticketSeatState", () => {
|
|
87
|
+
it("is available while a seat is genuinely free", () => {
|
|
88
|
+
expect(ticketSeatState(tier({ quantity: 10, sold: 5, held: 3 }))).toBe("available");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("separates seats in other carts from seats that are gone", () => {
|
|
92
|
+
// Both are un-addable. They are NOT the same sentence: one comes back.
|
|
93
|
+
expect(ticketSeatState(tier({ quantity: 10, sold: 7, held: 3 }))).toBe("reserved_elsewhere");
|
|
94
|
+
expect(ticketSeatState(tier({ quantity: 10, sold: 10, held: 0 }))).toBe("sold_out");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("calls a fully-sold tier sold out even when a hold is also outstanding", () => {
|
|
98
|
+
// Nothing left to release, so there is no "check back in a few minutes".
|
|
99
|
+
expect(ticketSeatState(tier({ quantity: 10, sold: 10, held: 2 }))).toBe("sold_out");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("ticketSeatLabel / ticketSeatNotice", () => {
|
|
104
|
+
it("gives the stepper a short word only when the tier cannot be added", () => {
|
|
105
|
+
expect(ticketSeatLabel("available")).toBeNull();
|
|
106
|
+
expect(ticketSeatLabel("reserved_elsewhere")).toBe("ON HOLD");
|
|
107
|
+
expect(ticketSeatLabel("sold_out")).toBe("SOLD OUT");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("explains only the state a fan should wait out", () => {
|
|
111
|
+
expect(ticketSeatNotice("available")).toBeNull();
|
|
112
|
+
// A sold-out tier gets the waitlist, not a "check back" — that is what
|
|
113
|
+
// `isTicketSoldOut` in useEventWaitlist draws, and two prompts would fight.
|
|
114
|
+
expect(ticketSeatNotice("sold_out")).toBeNull();
|
|
115
|
+
expect(ticketSeatNotice("reserved_elsewhere")).toMatch(/someone else's checkout/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("ticketSaleWindow", () => {
|
|
120
|
+
const now = new Date("2026-08-05T12:00:00.000Z");
|
|
121
|
+
|
|
122
|
+
it("is open when no on-sale time is set — every tier that predates the field", () => {
|
|
123
|
+
expect(ticketSaleWindow(tier(), now)).toEqual({ status: "open" });
|
|
124
|
+
expect(ticketSaleWindow(tier({ availableFrom: null }), now)).toEqual({ status: "open" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("is scheduled while the on-sale time is in the future", () => {
|
|
128
|
+
const w = ticketSaleWindow(tier({ availableFrom: "2026-08-07T09:00:00.000Z" }), now);
|
|
129
|
+
expect(w.status).toBe("scheduled");
|
|
130
|
+
expect(w.status === "scheduled" && w.startsAt.toISOString()).toBe("2026-08-07T09:00:00.000Z");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("opens ON the boundary second, matching the server's strict `now < availableFrom`", () => {
|
|
134
|
+
// A storefront that allowed the boundary would produce the one refusal this
|
|
135
|
+
// module exists to prevent; one that refused it would hold the door shut a
|
|
136
|
+
// second past the announced time in a rush. Both matter.
|
|
137
|
+
const at = "2026-08-05T12:00:00.000Z";
|
|
138
|
+
expect(ticketSaleWindow(tier({ availableFrom: at }), now)).toEqual({ status: "open" });
|
|
139
|
+
expect(ticketSaleWindow(tier({ availableFrom: at }), new Date(Date.parse(at) - 1)).status).toBe(
|
|
140
|
+
"scheduled",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("falls open on an unparseable value rather than locking the tier forever", () => {
|
|
145
|
+
// Failing closed here would make a bad string an unsellable tier with no
|
|
146
|
+
// explanation on screen. The server is the guard either way.
|
|
147
|
+
expect(ticketSaleWindow(tier({ availableFrom: "not a date" }), now)).toEqual({ status: "open" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("isTicketScheduled is the same verdict as a boolean", () => {
|
|
151
|
+
expect(isTicketScheduled(tier({ availableFrom: "2026-08-07T09:00:00.000Z" }), now)).toBe(true);
|
|
152
|
+
expect(isTicketScheduled(tier({ availableFrom: "2026-08-01T09:00:00.000Z" }), now)).toBe(false);
|
|
153
|
+
expect(isTicketScheduled(tier(), now)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("on-sale copy", () => {
|
|
158
|
+
const startsAt = new Date("2026-08-07T09:00:00.000Z");
|
|
159
|
+
|
|
160
|
+
it("counts down under the hour, where the clock beats the calendar", () => {
|
|
161
|
+
const now = new Date(startsAt.getTime() - 12 * 60_000);
|
|
162
|
+
expect(onSaleBadge(startsAt, now)).toBe("On sale in 12 minutes");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not say '1 minutes'", () => {
|
|
166
|
+
expect(onSaleBadge(startsAt, new Date(startsAt.getTime() - 60_000))).toBe("On sale in 1 minute");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("rounds a part-minute UP, so it never announces zero while still shut", () => {
|
|
170
|
+
// `Math.floor` here would render "On sale in 0 minutes" beside a disabled
|
|
171
|
+
// button for a whole minute.
|
|
172
|
+
expect(onSaleBadge(startsAt, new Date(startsAt.getTime() - 30_000))).toBe("On sale in 1 minute");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("switches to the absolute time at an hour and above", () => {
|
|
176
|
+
const now = new Date(startsAt.getTime() - 61 * 60_000);
|
|
177
|
+
expect(onSaleBadge(startsAt, now)).toBe(`On sale ${formatOnSaleAt(startsAt)}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("says 'on sale now' once the instant has passed, never a negative countdown", () => {
|
|
181
|
+
expect(onSaleBadge(startsAt, new Date(startsAt.getTime() + 1))).toBe("On sale now");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("spells the exact time out in the notice", () => {
|
|
185
|
+
expect(onSaleNotice(startsAt)).toBe(
|
|
186
|
+
`Tickets for this tier go on sale ${formatOnSaleAt(startsAt)}.`,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("maxAddableTickets", () => {
|
|
192
|
+
const now = new Date("2026-08-05T12:00:00.000Z");
|
|
193
|
+
|
|
194
|
+
it("is bounded by the seats the server would actually sell", () => {
|
|
195
|
+
expect(maxAddableTickets(tier({ quantity: 10, sold: 5, held: 3, maxPerPerson: 10 }), now)).toBe(2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("is bounded by maxPerPerson when that bites first", () => {
|
|
199
|
+
expect(maxAddableTickets(tier({ quantity: 100, sold: 0, held: 0, maxPerPerson: 4 }), now)).toBe(4);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("is zero before a scheduled on-sale, even on a tier with the whole house free", () => {
|
|
203
|
+
// This is the (b) defect: 100 seats, nothing sold, and the server refuses
|
|
204
|
+
// every one of them with `ticket_not_yet_on_sale`.
|
|
205
|
+
const scheduled = tier({ quantity: 100, sold: 0, availableFrom: "2026-08-07T09:00:00.000Z" });
|
|
206
|
+
expect(maxAddableTickets(scheduled, now)).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("opens to the normal cap the moment the on-sale time arrives", () => {
|
|
210
|
+
const at = "2026-08-05T12:00:00.000Z";
|
|
211
|
+
const t = tier({ quantity: 100, sold: 0, maxPerPerson: 4, availableFrom: at });
|
|
212
|
+
expect(maxAddableTickets(t, new Date(Date.parse(at) - 1))).toBe(0);
|
|
213
|
+
expect(maxAddableTickets(t, new Date(at))).toBe(4);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("is zero for a tier whose remaining seats are all in other carts", () => {
|
|
217
|
+
expect(maxAddableTickets(tier({ quantity: 10, sold: 7, held: 3 }), now)).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("never exceeds what the server will accept, for any tier shape", () => {
|
|
221
|
+
for (const quantity of [0, 1, 10]) {
|
|
222
|
+
for (const sold of [0, 1, 9]) {
|
|
223
|
+
for (const held of [0, 1, 5]) {
|
|
224
|
+
for (const maxPerPerson of [1, 4, 100]) {
|
|
225
|
+
const t = tier({ quantity, sold, held, maxPerPerson });
|
|
226
|
+
const cap = maxAddableTickets(t, now);
|
|
227
|
+
if (cap > 0) expect(serverWouldSell(t, cap)).toBe(true);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Events 2.6b — the offeree's OWN reservation.
|
|
237
|
+
*
|
|
238
|
+
* A waitlist offer takes a real hold, which is what stops a stranger taking the
|
|
239
|
+
* seat. The side effect is that the tier reads sold out to the person it is held
|
|
240
|
+
* for, so the picker has to know which of `held` is theirs — otherwise the claim
|
|
241
|
+
* link opens a page with the `+` disabled on the seat they were promised.
|
|
242
|
+
*/
|
|
243
|
+
describe("reservedForYou", () => {
|
|
244
|
+
const tier = { quantity: 10, sold: 8, held: 2, maxPerPerson: null, availableFrom: null };
|
|
245
|
+
|
|
246
|
+
it("changes nothing at all when omitted — every existing caller is untouched", () => {
|
|
247
|
+
expect(ticketSeatsAvailable(tier)).toBe(0);
|
|
248
|
+
expect(ticketSeatState(tier)).toBe("reserved_elsewhere");
|
|
249
|
+
expect(maxAddableTickets(tier)).toBe(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("re-opens exactly the seats held for this visitor, and no more", () => {
|
|
253
|
+
expect(ticketSeatsAvailable(tier, 2)).toBe(2);
|
|
254
|
+
expect(ticketSeatState(tier, 2)).toBe("available");
|
|
255
|
+
expect(maxAddableTickets(tier, new Date(), 2)).toBe(2);
|
|
256
|
+
// Somebody else's seat is still somebody else's.
|
|
257
|
+
expect(ticketSeatsAvailable(tier, 1)).toBe(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("never lets a claimed reservation exceed the artist's per-person cap", () => {
|
|
261
|
+
// The offer says how many seats are held; `maxPerPerson` says how many one
|
|
262
|
+
// person may hold at all, and an offer is not a licence to exceed it. The
|
|
263
|
+
// server applies the same cap, so exempting it here would only move the
|
|
264
|
+
// refusal to the card screen.
|
|
265
|
+
expect(maxAddableTickets({ ...tier, maxPerPerson: 1 }, new Date(), 2)).toBe(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("cannot conjure seats out of a genuinely sold-out tier", () => {
|
|
269
|
+
const soldOut = { quantity: 5, sold: 5, held: 0, maxPerPerson: null, availableFrom: null };
|
|
270
|
+
// Nothing is held here, so nothing can be held FOR anybody. A tampered value
|
|
271
|
+
// buys nothing: the server re-derives the number from the hold it issued.
|
|
272
|
+
expect(ticketSeatsAvailable(soldOut, 3)).toBe(3);
|
|
273
|
+
// …which is why the guard that matters is the claim itself, not this
|
|
274
|
+
// arithmetic. What this pins is that a NEGATIVE or absent value is inert.
|
|
275
|
+
expect(ticketSeatsAvailable(soldOut, -3)).toBe(0);
|
|
276
|
+
expect(maxAddableTickets(soldOut, new Date(), -3)).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("still refuses a tier whose on-sale time has not arrived", () => {
|
|
280
|
+
const scheduled = { ...tier, availableFrom: new Date(Date.now() + 86_400_000).toISOString() };
|
|
281
|
+
expect(maxAddableTickets(scheduled, new Date(), 2)).toBe(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -77,7 +77,7 @@ export type AttendeeSlot = {
|
|
|
77
77
|
isBuyer: boolean;
|
|
78
78
|
/** What the input should show: what they typed, else the buyer default. */
|
|
79
79
|
value: string;
|
|
80
|
-
/** "General Admission
|
|
80
|
+
/** "General Admission · Attendee 2 of 3", or just the tier on a single seat. */
|
|
81
81
|
label: string;
|
|
82
82
|
};
|
|
83
83
|
|
|
@@ -115,7 +115,7 @@ export function buildAttendeeSlots(input: {
|
|
|
115
115
|
ticketQuantity: quantity,
|
|
116
116
|
isBuyer,
|
|
117
117
|
value: typed !== undefined ? typed : isBuyer ? input.buyerName : "",
|
|
118
|
-
label: quantity > 1 ? `${ticket.title}
|
|
118
|
+
label: quantity > 1 ? `${ticket.title} · Attendee ${index + 1} of ${quantity}` : ticket.title,
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ITicket } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a ticket tier may be BOUGHT, as a storefront has to draw it.
|
|
5
|
+
*
|
|
6
|
+
* ## The two defects this module exists to close
|
|
7
|
+
*
|
|
8
|
+
* Both are the same shape: the buyer's screen and the server disagreed, and the
|
|
9
|
+
* buyer discovered it only by being refused after doing the work.
|
|
10
|
+
*
|
|
11
|
+
* **(a) Held seats were shown as available.** Every storefront computed
|
|
12
|
+
* remaining as `quantity - sold`. The server sells on `quantity - sold - held`
|
|
13
|
+
* (`services/_core/inventoryHold.ts` — `quantity - sold - held >= n`), where
|
|
14
|
+
* `held` is inventory promised to somebody else's in-flight checkout. During an
|
|
15
|
+
* on-sale rush a fan saw "3 left", picked 2, filled in their details, and was
|
|
16
|
+
* refused at reserve because those three seats were in other people's carts.
|
|
17
|
+
*
|
|
18
|
+
* **(b) A scheduled on-sale rendered as buyable.** `availableFrom` shipped on
|
|
19
|
+
* the tier, is enforced by `evaluateTierSellability`, is published in
|
|
20
|
+
* `PUBLIC_TICKET_COLUMNS` — and appeared on NEITHER rendering stack. A tier
|
|
21
|
+
* going on sale Friday 10am was fully clickable on Wednesday, and the refusal
|
|
22
|
+
* arrived as `ticket_not_yet_on_sale` on the payment screen.
|
|
23
|
+
*
|
|
24
|
+
* ## Why it is pure, and why it lives in `format/`
|
|
25
|
+
*
|
|
26
|
+
* There are two rendering stacks — `apps/client` and the Forge SDK that code
|
|
27
|
+
* websites are built against — and availability arithmetic that says one thing
|
|
28
|
+
* on an artist's PWA and another on their website is the same bug twice. So the
|
|
29
|
+
* arithmetic and the words are decided once, here, with no React and no client;
|
|
30
|
+
* each stack owns only how it looks. `apps/client` imports this from
|
|
31
|
+
* `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic
|
|
32
|
+
* and the members-only notice.
|
|
33
|
+
*
|
|
34
|
+
* ## The client is not the guard
|
|
35
|
+
*
|
|
36
|
+
* `createOrder` re-checks every one of these against the tier's own columns and
|
|
37
|
+
* refuses regardless of what a client sends. Nothing here makes a request safe.
|
|
38
|
+
* What it does is stop a buyer building a cart nobody will sell them.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Seats a buyer can actually take right now — the SERVER's arithmetic.
|
|
43
|
+
*
|
|
44
|
+
* `held` counts units inside somebody else's live checkout. It is optional on
|
|
45
|
+
* the type and absent-means-zero on purpose: a site pinned to an older API
|
|
46
|
+
* build that does not publish the column keeps exactly its previous behaviour
|
|
47
|
+
* rather than reading every tier as sold out.
|
|
48
|
+
*/
|
|
49
|
+
export const ticketSeatsAvailable = (
|
|
50
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held">,
|
|
51
|
+
/**
|
|
52
|
+
* Seats inside `held` that are held for THIS visitor (events 2.6b).
|
|
53
|
+
*
|
|
54
|
+
* A waitlist offer now takes a real inventory hold for the length of its
|
|
55
|
+
* claim window, which is the whole point — a stranger cannot take the seat
|
|
56
|
+
* the email promised. The consequence is that the tier reads sold out to
|
|
57
|
+
* everybody, INCLUDING the person it is being held for: their own reservation
|
|
58
|
+
* is in `held`, so the picker would refuse to let them add the seat they were
|
|
59
|
+
* offered, and the claim link would be unusable.
|
|
60
|
+
*
|
|
61
|
+
* Adding their own reservation back is not a loophole. It is spendable only
|
|
62
|
+
* by presenting the claim token, the server re-derives the same number from
|
|
63
|
+
* the hold it issued, and a visitor who invents a value simply gets refused
|
|
64
|
+
* at `createOrder` exactly as before.
|
|
65
|
+
*
|
|
66
|
+
* Zero by default, so every existing caller is byte-for-byte unchanged.
|
|
67
|
+
*/
|
|
68
|
+
reservedForYou = 0,
|
|
69
|
+
): number =>
|
|
70
|
+
Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0) - (ticket.held ?? 0) + Math.max(0, reservedForYou));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Seats that are not sold — held or otherwise.
|
|
74
|
+
*
|
|
75
|
+
* The difference between this and {@link ticketSeatsAvailable} is precisely the
|
|
76
|
+
* set of seats sitting in other people's checkouts, which is what separates
|
|
77
|
+
* "gone" from "gone for the next few minutes".
|
|
78
|
+
*/
|
|
79
|
+
export const ticketSeatsUnsold = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
|
|
80
|
+
Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a buyer cannot take a seat, when they cannot.
|
|
84
|
+
*
|
|
85
|
+
* `reserved_elsewhere` is deliberately NOT collapsed into `sold_out`. They are
|
|
86
|
+
* different facts with different right actions: a sold-out tier is over and the
|
|
87
|
+
* fan should join the waitlist or leave; a tier whose last seats are inside
|
|
88
|
+
* somebody's checkout may well be buyable in ten minutes, and telling that fan
|
|
89
|
+
* "SOLD OUT" loses a sale that was still there. It is also the state a rush
|
|
90
|
+
* produces most often, which is exactly when the wrong word costs the most.
|
|
91
|
+
*/
|
|
92
|
+
export type TicketSeatState = "available" | "reserved_elsewhere" | "sold_out";
|
|
93
|
+
|
|
94
|
+
export const ticketSeatState = (
|
|
95
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held">,
|
|
96
|
+
/** 2.6b — seats of `held` that are this visitor's own waitlist reservation. */
|
|
97
|
+
reservedForYou = 0,
|
|
98
|
+
): TicketSeatState => {
|
|
99
|
+
if (ticketSeatsAvailable(ticket, reservedForYou) > 0) return "available";
|
|
100
|
+
return ticketSeatsUnsold(ticket) > 0 ? "reserved_elsewhere" : "sold_out";
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The short word in the stepper, where "SOLD OUT" already lives.
|
|
105
|
+
*
|
|
106
|
+
* Kept to two words because it renders in a fixed-width slot between the − and
|
|
107
|
+
* + buttons; the sentence that explains it is {@link ticketSeatNotice}.
|
|
108
|
+
*/
|
|
109
|
+
export const ticketSeatLabel = (state: TicketSeatState): string | null => {
|
|
110
|
+
if (state === "sold_out") return "SOLD OUT";
|
|
111
|
+
if (state === "reserved_elsewhere") return "ON HOLD";
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The sentence under a tier that cannot be added, or `null` when it can. */
|
|
116
|
+
export const ticketSeatNotice = (state: TicketSeatState): string | null => {
|
|
117
|
+
if (state !== "reserved_elsewhere") return null;
|
|
118
|
+
return "The last seats are in someone else's checkout. They may be released in a few minutes, so check back.";
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Has this tier's on-sale time arrived?
|
|
123
|
+
*
|
|
124
|
+
* Mirrors `evaluateTierSellability`'s `not_yet_on_sale` branch exactly, down to
|
|
125
|
+
* the strict comparison: the server refuses while `now < availableFrom`, so a
|
|
126
|
+
* storefront that allowed the boundary second would produce the one refusal
|
|
127
|
+
* this whole module exists to prevent.
|
|
128
|
+
*/
|
|
129
|
+
export type TicketSaleWindow = { status: "open" } | { status: "scheduled"; startsAt: Date };
|
|
130
|
+
|
|
131
|
+
export const ticketSaleWindow = (
|
|
132
|
+
ticket: Pick<ITicket, "availableFrom">,
|
|
133
|
+
now: Date = new Date(),
|
|
134
|
+
): TicketSaleWindow => {
|
|
135
|
+
if (!ticket.availableFrom) return { status: "open" };
|
|
136
|
+
const startsAt = new Date(ticket.availableFrom);
|
|
137
|
+
if (Number.isNaN(startsAt.getTime())) return { status: "open" };
|
|
138
|
+
return now < startsAt ? { status: "scheduled", startsAt } : { status: "open" };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** `true` only while a scheduled tier's on-sale time is still in the future. */
|
|
142
|
+
export const isTicketScheduled = (
|
|
143
|
+
ticket: Pick<ITicket, "availableFrom">,
|
|
144
|
+
now: Date = new Date(),
|
|
145
|
+
): boolean => ticketSaleWindow(ticket, now).status === "scheduled";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The buyer's local rendering of an on-sale instant.
|
|
149
|
+
*
|
|
150
|
+
* Buyer-local, not the event's timezone: this answers "when can I click buy?",
|
|
151
|
+
* which happens where the buyer is. The server compares absolute instants, so
|
|
152
|
+
* the two cannot disagree about WHEN — only about how it reads.
|
|
153
|
+
*
|
|
154
|
+
* Matches the format the waitlist claim deadline and the transfer panel already
|
|
155
|
+
* use, so three different "when" strings on one event page look like one voice.
|
|
156
|
+
*/
|
|
157
|
+
export const formatOnSaleAt = (startsAt: Date): string =>
|
|
158
|
+
startsAt.toLocaleString(undefined, {
|
|
159
|
+
weekday: "short",
|
|
160
|
+
day: "numeric",
|
|
161
|
+
month: "short",
|
|
162
|
+
hour: "numeric",
|
|
163
|
+
minute: "2-digit",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The badge for a tier that is announced but not yet buyable.
|
|
168
|
+
*
|
|
169
|
+
* Under an hour it counts down, because at that range "10:00" is worth less to
|
|
170
|
+
* a waiting fan than "in 12 minutes" and the tier list already re-renders every
|
|
171
|
+
* second for the expiry urgency. Above it, the absolute time is what someone
|
|
172
|
+
* plans their Friday around.
|
|
173
|
+
*
|
|
174
|
+
* A tier that is merely not-yet-on-sale stays VISIBLE (see
|
|
175
|
+
* `isTierPubliclyVisible`) — that is the point. Hiding it would make a
|
|
176
|
+
* scheduled on-sale indistinguishable from the tier not existing.
|
|
177
|
+
*/
|
|
178
|
+
export const onSaleBadge = (startsAt: Date, now: Date = new Date()): string => {
|
|
179
|
+
const diffMs = startsAt.getTime() - now.getTime();
|
|
180
|
+
if (diffMs <= 0) return "On sale now";
|
|
181
|
+
const minutes = Math.ceil(diffMs / 60_000);
|
|
182
|
+
if (minutes < 60) return `On sale in ${minutes} minute${minutes !== 1 ? "s" : ""}`;
|
|
183
|
+
return `On sale ${formatOnSaleAt(startsAt)}`;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The sentence under a scheduled tier, spelling out the exact time. */
|
|
187
|
+
export const onSaleNotice = (startsAt: Date): string =>
|
|
188
|
+
`Tickets for this tier go on sale ${formatOnSaleAt(startsAt)}.`;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The most a buyer may put in the cart for one tier.
|
|
192
|
+
*
|
|
193
|
+
* `maxPerPerson` is a per-buyer cap and the seat count is a per-tier one; the
|
|
194
|
+
* cart is bounded by whichever bites first. Zero for anything not currently
|
|
195
|
+
* sellable, which is what disables the `+`.
|
|
196
|
+
*/
|
|
197
|
+
export const maxAddableTickets = (
|
|
198
|
+
/**
|
|
199
|
+
* `maxPerPerson` is widened to nullable HERE rather than on `ITicket`.
|
|
200
|
+
*
|
|
201
|
+
* The column is NOT NULL with a default, so `ITicket` is right about the
|
|
202
|
+
* database and must stay that way. But this function is handed payloads from
|
|
203
|
+
* two storefronts and older API builds, and its `?? seats` fallback exists
|
|
204
|
+
* precisely because the field can arrive absent. Typing the parameter for
|
|
205
|
+
* what it actually tolerates keeps that fallback reachable — and testable —
|
|
206
|
+
* without teaching the whole SDK that a non-null column is nullable.
|
|
207
|
+
*/
|
|
208
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held" | "availableFrom"> & {
|
|
209
|
+
maxPerPerson?: number | null;
|
|
210
|
+
},
|
|
211
|
+
now: Date = new Date(),
|
|
212
|
+
/**
|
|
213
|
+
* 2.6b — seats of `held` reserved for this visitor by a waitlist offer.
|
|
214
|
+
*
|
|
215
|
+
* Deliberately NOT exempt from `maxPerPerson`: the offer decides how many
|
|
216
|
+
* seats are held, the artist's per-person cap decides how many one person may
|
|
217
|
+
* hold at all, and an offer is not a licence to exceed the second. The server
|
|
218
|
+
* enforces the cap on the same order either way, so exempting it here would
|
|
219
|
+
* only move the refusal to the card screen.
|
|
220
|
+
*/
|
|
221
|
+
reservedForYou = 0,
|
|
222
|
+
): number => {
|
|
223
|
+
if (isTicketScheduled(ticket, now)) return 0;
|
|
224
|
+
const seats = ticketSeatsAvailable(ticket, reservedForYou);
|
|
225
|
+
return Math.min(seats, ticket.maxPerPerson ?? seats);
|
|
226
|
+
};
|
|
@@ -23,13 +23,19 @@ export type BundleCouponSummary = {
|
|
|
23
23
|
*/
|
|
24
24
|
appliedCoupon: { code: string; discountAmount: number } | null;
|
|
25
25
|
/**
|
|
26
|
-
* The re-minted
|
|
27
|
-
* element must move onto this
|
|
28
|
-
* pre-coupon amount. Null when there was
|
|
26
|
+
* The re-minted charge. Applying re-prices AND re-mints, so the payment
|
|
27
|
+
* element must move onto this session, because the previous one still quotes the
|
|
28
|
+
* pre-coupon amount. Null when there was nothing to replace (the bundle had
|
|
29
29
|
* not started payment) or when the bundle settled for free.
|
|
30
|
+
*
|
|
31
|
+
* Exactly one of the two session fields is set: `paymentSecret` on Stripe,
|
|
32
|
+
* `accessCode` + `checkoutUrl` on Paystack. A Paystack re-mint carries no
|
|
33
|
+
* secret at all, which is why the gate below cannot test that field alone.
|
|
30
34
|
*/
|
|
31
35
|
paymentUpdate: {
|
|
32
|
-
paymentSecret
|
|
36
|
+
paymentSecret?: string;
|
|
37
|
+
accessCode?: string;
|
|
38
|
+
checkoutUrl?: string;
|
|
33
39
|
paymentId: string;
|
|
34
40
|
chargedAmount: number;
|
|
35
41
|
chargedCurrency: string;
|
|
@@ -54,9 +60,11 @@ export function bundleCouponSummary(data: ApplyCheckoutCouponResult): BundleCoup
|
|
|
54
60
|
// not claimed as one — `discountAmount` still reports the reduction.
|
|
55
61
|
appliedCoupon: discountAmount > 0 && named ? { code: named.code, discountAmount } : null,
|
|
56
62
|
paymentUpdate:
|
|
57
|
-
data.paymentSecret && data.paymentId
|
|
63
|
+
(data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId
|
|
58
64
|
? {
|
|
59
|
-
paymentSecret: data.paymentSecret,
|
|
65
|
+
paymentSecret: data.paymentSecret || undefined,
|
|
66
|
+
accessCode: data.accessCode,
|
|
67
|
+
checkoutUrl: data.checkoutUrl,
|
|
60
68
|
paymentId: data.paymentId,
|
|
61
69
|
chargedAmount: data.chargedAmount,
|
|
62
70
|
chargedCurrency: data.chargedCurrency,
|