@tribe-nest/forge 3.21.0 → 3.23.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/_tests/passTransfers.spec.ts +100 -4
- 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 +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- 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 +5 -0
- package/src/server/index.ts +52 -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 +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- 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 +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -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/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- 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/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { AlertCircle, Clock, X } from "lucide-react";
|
|
2
|
+
import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
|
|
3
3
|
import type { IEvent, ITicket } from "../../types/models";
|
|
4
4
|
import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
8
|
import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
|
|
9
|
+
import {
|
|
10
|
+
maxAddableTickets,
|
|
11
|
+
onSaleBadge,
|
|
12
|
+
onSaleNotice,
|
|
13
|
+
ticketSaleWindow,
|
|
14
|
+
ticketSeatLabel,
|
|
15
|
+
ticketSeatNotice,
|
|
16
|
+
ticketSeatState,
|
|
17
|
+
} from "../format/ticketAvailability";
|
|
9
18
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
10
19
|
import { Loading } from "./Loading";
|
|
20
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
11
21
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
12
22
|
import { buttonStyle } from "./Button";
|
|
23
|
+
import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
13
24
|
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
25
|
+
import { PresaleCodeField } from "./PresaleCode";
|
|
26
|
+
import { HoldNotice } from "./HoldNotice";
|
|
14
27
|
import {
|
|
15
28
|
DialogRoot,
|
|
16
29
|
DialogTrigger,
|
|
@@ -34,6 +47,13 @@ export interface EventTicketsProps {
|
|
|
34
47
|
finalisePath?: (slug: string, orderId: string) => string;
|
|
35
48
|
/** Host-owned navigation on FREE completion (e.g. router navigate). */
|
|
36
49
|
onComplete?: (info: { slug: string; orderId: string }) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Where a members-only tier sends a signed-OUT visitor. Default `/login`;
|
|
52
|
+
* code sites mount theirs under `/i`. The sign-in returns them here.
|
|
53
|
+
*/
|
|
54
|
+
loginPath?: string;
|
|
55
|
+
/** Where a members-only tier sends a signed-in NON-member — the tier listing. */
|
|
56
|
+
membershipPath?: string;
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
@@ -51,6 +71,8 @@ export function EventTickets({
|
|
|
51
71
|
renderPayment,
|
|
52
72
|
finalisePath,
|
|
53
73
|
onComplete,
|
|
74
|
+
loginPath,
|
|
75
|
+
membershipPath,
|
|
54
76
|
}: EventTicketsProps) {
|
|
55
77
|
const theme = useForgeTheme();
|
|
56
78
|
const registered = usePaymentRenderer();
|
|
@@ -109,6 +131,8 @@ export function EventTickets({
|
|
|
109
131
|
event={c.event}
|
|
110
132
|
fmt={fmt}
|
|
111
133
|
renderPayment={renderPay}
|
|
134
|
+
loginPath={loginPath}
|
|
135
|
+
membershipPath={membershipPath}
|
|
112
136
|
// Selection is safe in the cart now — close so the buyer can go
|
|
113
137
|
// browse the add-ons.
|
|
114
138
|
onAddedToCart={() => setOpen(false)}
|
|
@@ -128,19 +152,34 @@ function EventTicketsBody({
|
|
|
128
152
|
fmt,
|
|
129
153
|
renderPayment,
|
|
130
154
|
onAddedToCart,
|
|
155
|
+
loginPath,
|
|
156
|
+
membershipPath,
|
|
131
157
|
}: {
|
|
132
158
|
c: Checkout;
|
|
133
159
|
event: IEvent;
|
|
134
160
|
fmt: (n: number) => string;
|
|
135
161
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
136
162
|
onAddedToCart?: () => void;
|
|
163
|
+
loginPath?: string;
|
|
164
|
+
membershipPath?: string;
|
|
137
165
|
}) {
|
|
138
166
|
const stepIndex = c.step === "tickets" ? 0 : c.step === "details" ? 1 : 2;
|
|
139
167
|
return (
|
|
140
168
|
<div style={{ width: "100%" }}>
|
|
141
169
|
<Progress current={stepIndex} />
|
|
142
|
-
{c.step === "tickets" &&
|
|
143
|
-
|
|
170
|
+
{c.step === "tickets" && (
|
|
171
|
+
<TicketStep
|
|
172
|
+
c={c}
|
|
173
|
+
event={event}
|
|
174
|
+
fmt={fmt}
|
|
175
|
+
onAddedToCart={onAddedToCart}
|
|
176
|
+
loginPath={loginPath}
|
|
177
|
+
membershipPath={membershipPath}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
{c.step === "details" && (
|
|
181
|
+
<DetailsStep c={c} event={event} fmt={fmt} loginPath={loginPath} membershipPath={membershipPath} />
|
|
182
|
+
)}
|
|
144
183
|
{c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
|
|
145
184
|
</div>
|
|
146
185
|
);
|
|
@@ -193,11 +232,15 @@ function TicketStep({
|
|
|
193
232
|
event,
|
|
194
233
|
fmt,
|
|
195
234
|
onAddedToCart,
|
|
235
|
+
loginPath,
|
|
236
|
+
membershipPath,
|
|
196
237
|
}: {
|
|
197
238
|
c: Checkout;
|
|
198
239
|
event: IEvent;
|
|
199
240
|
fmt: (n: number) => string;
|
|
200
241
|
onAddedToCart?: () => void;
|
|
242
|
+
loginPath?: string;
|
|
243
|
+
membershipPath?: string;
|
|
201
244
|
}) {
|
|
202
245
|
const theme = useForgeTheme();
|
|
203
246
|
// Inclusive stores caption ticket prices as tax-inclusive (display only).
|
|
@@ -225,23 +268,65 @@ function TicketStep({
|
|
|
225
268
|
};
|
|
226
269
|
|
|
227
270
|
const setQty = (ticket: ITicket, qty: number) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
271
|
+
// `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
|
|
272
|
+
// on-sale — the same two facts the server sells on. Computed off `now` so
|
|
273
|
+
// the cap opens up on the tick the on-sale time passes, with no reload.
|
|
274
|
+
//
|
|
275
|
+
// 2.6b adds back the seats held for THIS visitor by a waitlist offer.
|
|
276
|
+
// Without it the offeree's own reservation makes the tier read sold out to
|
|
277
|
+
// them and the claim link is unusable.
|
|
278
|
+
c.setTicketQty(
|
|
279
|
+
ticket.id,
|
|
280
|
+
Math.max(0, Math.min(qty, maxAddableTickets(ticket, new Date(now), c.reservedForYou[ticket.id] ?? 0))),
|
|
281
|
+
);
|
|
231
282
|
};
|
|
232
283
|
|
|
233
284
|
return (
|
|
234
285
|
<div>
|
|
235
286
|
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Select Tickets</h2>
|
|
287
|
+
{/* Renders nothing on an event with no coded tier (1.2). Here as well as
|
|
288
|
+
on the page because this modal is reachable on its own — from an offer,
|
|
289
|
+
and from the broadcast-pass prompt — where there is no page-level box.
|
|
290
|
+
Both read the SAME stored code, so unlocking in one unlocks the other. */}
|
|
291
|
+
<PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
|
|
236
292
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
237
293
|
{event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
|
|
238
294
|
{event.tickets.map((ticket) => {
|
|
239
295
|
const selectedQty = c.selectedTickets[ticket.id] ?? 0;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
296
|
+
/**
|
|
297
|
+
* `quantity - sold - HELD`, which is what the server sells on. The
|
|
298
|
+
* old `quantity - sold` showed seats sitting in other buyers' live
|
|
299
|
+
* checkouts as available, so a fan in a rush was told "3 left",
|
|
300
|
+
* picked two, filled in the form and was refused at reserve.
|
|
301
|
+
*/
|
|
302
|
+
// 2.6b — the offeree's own reservation is not "somebody else's
|
|
303
|
+
// checkout"; it is the seat they were emailed a deadline for.
|
|
304
|
+
const reserved = c.reservedForYou[ticket.id] ?? 0;
|
|
305
|
+
const seatState = ticketSeatState(ticket, reserved);
|
|
306
|
+
const maxAllowed = maxAddableTickets(ticket, new Date(now), reserved);
|
|
307
|
+
const isSoldOut = seatState !== "available";
|
|
308
|
+
const seatLabel = ticketSeatLabel(seatState);
|
|
309
|
+
const seatNotice = ticketSeatNotice(seatState);
|
|
310
|
+
/**
|
|
311
|
+
* Scheduled on-sale. Enforced since 1.2 and drawn on no storefront,
|
|
312
|
+
* so a tier going on sale Friday was clickable on Wednesday and the
|
|
313
|
+
* `ticket_not_yet_on_sale` refusal arrived on the payment screen.
|
|
314
|
+
*/
|
|
315
|
+
const saleWindow = ticketSaleWindow(ticket, new Date(now));
|
|
316
|
+
const isScheduled = saleWindow.status === "scheduled";
|
|
243
317
|
const u = urgency(ticket.expiresAt);
|
|
244
318
|
const isExpired = !!u?.expired;
|
|
319
|
+
/**
|
|
320
|
+
* A members-only tier the buyer cannot have (S.4).
|
|
321
|
+
*
|
|
322
|
+
* The event-level gate applies to EVERY tier — "no ticket to this show
|
|
323
|
+
* sells to a non-member" — so it is folded in here rather than drawn
|
|
324
|
+
* once at the top: a buyer reading one tier row must be able to tell
|
|
325
|
+
* from that row whether they can buy it.
|
|
326
|
+
*/
|
|
327
|
+
const gate =
|
|
328
|
+
event.membershipGate && !event.membershipGate.allowed ? event.membershipGate : ticket.membershipGate;
|
|
329
|
+
const isGated = !!gate && !gate.allowed;
|
|
245
330
|
const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
|
|
246
331
|
return (
|
|
247
332
|
<div
|
|
@@ -257,6 +342,59 @@ function TicketStep({
|
|
|
257
342
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
|
|
258
343
|
<div style={{ flex: 1 }}>
|
|
259
344
|
<h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
|
|
345
|
+
{/* A hidden tier is only ever on screen because a code
|
|
346
|
+
revealed it — so say so. Without the badge, a code that
|
|
347
|
+
unlocked a tier the buyer had not seen listed looks like it
|
|
348
|
+
did nothing. */}
|
|
349
|
+
{ticket.isHidden && (
|
|
350
|
+
<div
|
|
351
|
+
data-testid="presale-tier-badge"
|
|
352
|
+
style={{
|
|
353
|
+
display: "inline-flex",
|
|
354
|
+
alignItems: "center",
|
|
355
|
+
gap: 4,
|
|
356
|
+
padding: "2px 8px",
|
|
357
|
+
borderRadius: 6,
|
|
358
|
+
fontSize: 12,
|
|
359
|
+
fontWeight: 700,
|
|
360
|
+
marginBottom: 6,
|
|
361
|
+
marginRight: 6,
|
|
362
|
+
background: a(theme.colors.primary, 0.1),
|
|
363
|
+
color: theme.colors.primary,
|
|
364
|
+
border: `1px solid ${a(theme.colors.primary, 0.3)}`,
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
<KeyRound size={12} />
|
|
368
|
+
Presale
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
{/* A tier that is announced but not yet buyable says WHEN,
|
|
372
|
+
in the same badge idiom as the presale and expiry ones.
|
|
373
|
+
Deliberately not dimmed and deliberately not hidden: the
|
|
374
|
+
whole point of scheduling an on-sale is that fans can see
|
|
375
|
+
it coming. */}
|
|
376
|
+
{isScheduled && (
|
|
377
|
+
<div
|
|
378
|
+
data-testid="ticket-on-sale-badge"
|
|
379
|
+
style={{
|
|
380
|
+
display: "inline-flex",
|
|
381
|
+
alignItems: "center",
|
|
382
|
+
gap: 4,
|
|
383
|
+
padding: "2px 8px",
|
|
384
|
+
borderRadius: 6,
|
|
385
|
+
fontSize: 12,
|
|
386
|
+
fontWeight: 700,
|
|
387
|
+
marginBottom: 6,
|
|
388
|
+
marginRight: 6,
|
|
389
|
+
background: a(theme.colors.primary, 0.1),
|
|
390
|
+
color: theme.colors.primary,
|
|
391
|
+
border: `1px solid ${a(theme.colors.primary, 0.3)}`,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<CalendarClock size={12} />
|
|
395
|
+
{onSaleBadge(saleWindow.startsAt, new Date(now))}
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
260
398
|
{u && !u.expired && (
|
|
261
399
|
<div
|
|
262
400
|
style={{
|
|
@@ -295,17 +433,28 @@ function TicketStep({
|
|
|
295
433
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
296
434
|
<button
|
|
297
435
|
onClick={() => setQty(ticket, selectedQty - 1)}
|
|
298
|
-
disabled
|
|
436
|
+
// Never disabled by availability — only by there being
|
|
437
|
+
// nothing to remove. A buyer holding two seats on a tier
|
|
438
|
+
// that has just gone unavailable must still be able to put
|
|
439
|
+
// them back; disabling this would trap the cart.
|
|
440
|
+
disabled={selectedQty === 0}
|
|
299
441
|
style={stepperStyle(theme, false)}
|
|
300
442
|
>
|
|
301
443
|
−
|
|
302
444
|
</button>
|
|
303
445
|
<span style={{ fontSize: 18, fontWeight: 700, minWidth: 32, textAlign: "center" }}>
|
|
304
|
-
{
|
|
446
|
+
{seatLabel ?? selectedQty}
|
|
305
447
|
</span>
|
|
306
448
|
<button
|
|
307
449
|
onClick={() => setQty(ticket, selectedQty + 1)}
|
|
308
|
-
|
|
450
|
+
// A gated tier cannot be added, nor one whose on-sale time
|
|
451
|
+
// has not arrived, nor one whose remaining seats are inside
|
|
452
|
+
// somebody else's checkout. Not because the client is the
|
|
453
|
+
// authority — `createOrder` refuses all three regardless —
|
|
454
|
+
// but because letting a buyer build a cart the server will
|
|
455
|
+
// not sell is precisely the "discoverable only by failing"
|
|
456
|
+
// journey this change exists to end.
|
|
457
|
+
disabled={selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled}
|
|
309
458
|
style={stepperStyle(theme, true)}
|
|
310
459
|
>
|
|
311
460
|
+
|
|
@@ -316,7 +465,7 @@ function TicketStep({
|
|
|
316
465
|
before that there is nothing to price, and a row of empty
|
|
317
466
|
inputs down the tier list reads as a form to fill in rather
|
|
318
467
|
than a choice to make. */}
|
|
319
|
-
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
|
|
468
|
+
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
|
|
320
469
|
<PwywAmountField
|
|
321
470
|
ticket={ticket}
|
|
322
471
|
quantity={selectedQty}
|
|
@@ -329,6 +478,35 @@ function TicketStep({
|
|
|
329
478
|
{ticket.description && (
|
|
330
479
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
331
480
|
)}
|
|
481
|
+
{/* Named on the TIER, before the buyer has typed anything. The
|
|
482
|
+
tier stays in the list on purpose — hiding it kills the upsell
|
|
483
|
+
— so this is what turns "visible but mysteriously unbuyable"
|
|
484
|
+
into an offer with a way in. */}
|
|
485
|
+
<MembershipGateNotice
|
|
486
|
+
gate={gate}
|
|
487
|
+
variant="badge"
|
|
488
|
+
itemLabel={event.membershipGate && !event.membershipGate.allowed ? "This event" : "This ticket"}
|
|
489
|
+
loginPath={loginPath}
|
|
490
|
+
membershipPath={membershipPath}
|
|
491
|
+
/>
|
|
492
|
+
{/* The badge says WHEN in two words; this says it in full, with
|
|
493
|
+
the exact time, for a fan deciding whether to set an alarm. */}
|
|
494
|
+
{isScheduled && (
|
|
495
|
+
<div data-testid="ticket-on-sale-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
|
|
496
|
+
<CalendarClock size={16} />
|
|
497
|
+
{onSaleNotice(saleWindow.startsAt)}
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
{/* "ON HOLD" in the stepper is two words; this is the sentence
|
|
501
|
+
that stops it reading as a dead end. Seats inside somebody
|
|
502
|
+
else's checkout come back if that checkout lapses, which a
|
|
503
|
+
fan told only "SOLD OUT" would never wait for. */}
|
|
504
|
+
{seatNotice && (
|
|
505
|
+
<div data-testid="ticket-on-hold-notice" style={infoNoticeStyle("#f59e0b", theme)}>
|
|
506
|
+
<Clock size={16} />
|
|
507
|
+
{seatNotice}
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
332
510
|
{isExpired && (
|
|
333
511
|
<div
|
|
334
512
|
style={{
|
|
@@ -375,7 +553,19 @@ function TicketStep({
|
|
|
375
553
|
}
|
|
376
554
|
|
|
377
555
|
// ── Step 2: buyer details + questionnaire ─────────────────────────────────────
|
|
378
|
-
function DetailsStep({
|
|
556
|
+
function DetailsStep({
|
|
557
|
+
c,
|
|
558
|
+
event,
|
|
559
|
+
fmt,
|
|
560
|
+
loginPath,
|
|
561
|
+
membershipPath,
|
|
562
|
+
}: {
|
|
563
|
+
c: Checkout;
|
|
564
|
+
event: IEvent;
|
|
565
|
+
fmt: (n: number) => string;
|
|
566
|
+
loginPath?: string;
|
|
567
|
+
membershipPath?: string;
|
|
568
|
+
}) {
|
|
379
569
|
const theme = useForgeTheme();
|
|
380
570
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
381
571
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
@@ -451,6 +641,50 @@ function DetailsStep({ c, event, fmt }: { c: Checkout; event: IEvent; fmt: (n: n
|
|
|
451
641
|
</div>
|
|
452
642
|
)}
|
|
453
643
|
|
|
644
|
+
{/* The refusal path. It should be unreachable now that the tier itself
|
|
645
|
+
is badged, but a membership can lapse mid-checkout and a page can be
|
|
646
|
+
served from cache — and when it happens the buyer gets the tier's
|
|
647
|
+
name and a way in, not a red line reading "Something went wrong."
|
|
648
|
+
`c.error` is deliberately not set for this case, so the two cannot
|
|
649
|
+
both draw. */}
|
|
650
|
+
<MembershipGateNotice
|
|
651
|
+
refusal={c.gateRefusal}
|
|
652
|
+
variant="card"
|
|
653
|
+
itemLabel="This ticket"
|
|
654
|
+
loginPath={loginPath}
|
|
655
|
+
membershipPath={membershipPath}
|
|
656
|
+
style={{ marginTop: 4 }}
|
|
657
|
+
/>
|
|
658
|
+
|
|
659
|
+
{c.attendeeSlots.length > 0 && (
|
|
660
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
|
|
661
|
+
<div>
|
|
662
|
+
<h3 style={{ fontSize: 16, fontWeight: 700 }}>Who is coming?</h3>
|
|
663
|
+
<p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
|
|
664
|
+
This event prints each attendee’s name on their own pass, so the door list matches who
|
|
665
|
+
actually turns up.
|
|
666
|
+
</p>
|
|
667
|
+
</div>
|
|
668
|
+
{c.attendeeSlots.map((slot) => (
|
|
669
|
+
<label
|
|
670
|
+
key={`${slot.ticketId}-${slot.index}`}
|
|
671
|
+
style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}
|
|
672
|
+
>
|
|
673
|
+
<span>
|
|
674
|
+
{slot.label}
|
|
675
|
+
{slot.isBuyer && <span style={{ opacity: 0.6 }}> (that’s you)</span>}
|
|
676
|
+
</span>
|
|
677
|
+
<input
|
|
678
|
+
placeholder="Full name"
|
|
679
|
+
value={slot.value}
|
|
680
|
+
onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
|
|
681
|
+
style={inputStyle(theme)}
|
|
682
|
+
/>
|
|
683
|
+
</label>
|
|
684
|
+
))}
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
687
|
+
|
|
454
688
|
{(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
|
|
455
689
|
</div>
|
|
456
690
|
<ActionBar
|
|
@@ -484,6 +718,10 @@ function PaymentStep({
|
|
|
484
718
|
<div>
|
|
485
719
|
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
|
|
486
720
|
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
|
|
721
|
+
{/* The reservation clock. Event ticket holds are taken unconditionally —
|
|
722
|
+
no feature switch — so this is the one checkout where the countdown
|
|
723
|
+
fires for every profile, and it was the one that did not render it. */}
|
|
724
|
+
<HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
|
|
487
725
|
<div
|
|
488
726
|
style={{
|
|
489
727
|
padding: 20,
|
|
@@ -544,7 +782,15 @@ function PaymentStep({
|
|
|
544
782
|
</span>
|
|
545
783
|
</div>
|
|
546
784
|
</div>
|
|
547
|
-
{
|
|
785
|
+
{/* Paystack needs no registered renderer at all (it has its own modal),
|
|
786
|
+
so it is checked BEFORE the "not configured" branch. */}
|
|
787
|
+
{c.isPaystack ? (
|
|
788
|
+
c.canOpenPaystack ? (
|
|
789
|
+
<PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(c.totalAmount)}`} />
|
|
790
|
+
) : (
|
|
791
|
+
<p style={{ opacity: 0.7 }}>Preparing payment…</p>
|
|
792
|
+
)
|
|
793
|
+
) : !renderPayment ? (
|
|
548
794
|
<p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
|
|
549
795
|
) : c.clientSecret ? (
|
|
550
796
|
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
|
|
@@ -754,8 +1000,8 @@ function PwywAmountField({
|
|
|
754
1000
|
Name your price
|
|
755
1001
|
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
756
1002
|
{" "}
|
|
757
|
-
|
|
758
|
-
{maximum != null ? `, up to ${fmt(maximum)}` : ""}
|
|
1003
|
+
(minimum {fmt(floor)}
|
|
1004
|
+
{maximum != null ? `, up to ${fmt(maximum)}` : ""})
|
|
759
1005
|
</span>
|
|
760
1006
|
</label>
|
|
761
1007
|
<input
|
|
@@ -772,9 +1018,9 @@ function PwywAmountField({
|
|
|
772
1018
|
/>
|
|
773
1019
|
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
774
1020
|
{belowFloor
|
|
775
|
-
? `The minimum is ${fmt(floor)}
|
|
1021
|
+
? `The minimum is ${fmt(floor)}, so we'll use that.`
|
|
776
1022
|
: aboveMax
|
|
777
|
-
? `The most you can pay is ${fmt(maximum!)}
|
|
1023
|
+
? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
|
|
778
1024
|
: quantity > 1
|
|
779
1025
|
? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
|
|
780
1026
|
: "Per ticket."}
|
|
@@ -783,6 +1029,25 @@ function PwywAmountField({
|
|
|
783
1029
|
);
|
|
784
1030
|
}
|
|
785
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* The inline banner under a tier row — same shape as the expired one already
|
|
1034
|
+
* below it, so "why can't I buy this?" always answers itself in one place and
|
|
1035
|
+
* one visual language, whichever the reason.
|
|
1036
|
+
*/
|
|
1037
|
+
const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
|
|
1038
|
+
marginTop: 12,
|
|
1039
|
+
padding: "8px 12px",
|
|
1040
|
+
borderRadius: t.cornerRadius,
|
|
1041
|
+
fontSize: 14,
|
|
1042
|
+
fontWeight: 600,
|
|
1043
|
+
background: a(color, 0.1),
|
|
1044
|
+
color,
|
|
1045
|
+
border: `1px solid ${a(color, 0.3)}`,
|
|
1046
|
+
display: "flex",
|
|
1047
|
+
alignItems: "center",
|
|
1048
|
+
gap: 8,
|
|
1049
|
+
});
|
|
1050
|
+
|
|
786
1051
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
787
1052
|
width: 40,
|
|
788
1053
|
height: 40,
|
|
@@ -168,7 +168,9 @@ function WaitlistPlaceCard({
|
|
|
168
168
|
{offerOpen ? (
|
|
169
169
|
<span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
|
|
170
170
|
<BellRing size={16} />
|
|
171
|
-
|
|
171
|
+
{/* 2.6b — "reserved for you" is a materially different promise from
|
|
172
|
+
"available", and the fan acts differently on each. */}
|
|
173
|
+
{entry.offer.seatsReserved ? "Reserved for you" : "Tickets available"}
|
|
172
174
|
</span>
|
|
173
175
|
) : entry.position != null ? (
|
|
174
176
|
<span style={{ fontSize: 14, opacity: 0.85 }}>
|
|
@@ -213,6 +215,9 @@ function WaitlistPlaceCard({
|
|
|
213
215
|
*/
|
|
214
216
|
function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
|
|
215
217
|
const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
|
|
218
|
+
// Absent on a payload from an older API build — read as "not reserved", which
|
|
219
|
+
// is the weaker and therefore safe reading.
|
|
220
|
+
const reserved = entry.offer.seatsReserved === true;
|
|
216
221
|
|
|
217
222
|
useEffect(() => {
|
|
218
223
|
setRemaining(entry.offer.secondsRemaining ?? 0);
|
|
@@ -254,8 +259,23 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
|
|
|
254
259
|
</div>
|
|
255
260
|
{deadline && (
|
|
256
261
|
<div style={{ opacity: 0.8, marginTop: 4 }}>
|
|
257
|
-
{
|
|
258
|
-
|
|
262
|
+
{/*
|
|
263
|
+
2.6b — the sentence is chosen by whether the seats are actually
|
|
264
|
+
HELD, not by whether an offer exists.
|
|
265
|
+
|
|
266
|
+
The old copy said both things at once — "your tickets are held
|
|
267
|
+
until Friday" and "tickets are not reserved" — because the second
|
|
268
|
+
was true and the first was aspirational. Now one of them is true per
|
|
269
|
+
row: a reserved offer really does keep the seats off the market for
|
|
270
|
+
the window, and a grandfathered one really does not. Saying the
|
|
271
|
+
strong version of an offer that reserves nothing is the exact
|
|
272
|
+
failure this change closes.
|
|
273
|
+
*/}
|
|
274
|
+
{remaining <= 0
|
|
275
|
+
? `Your offer expired at ${deadline}.`
|
|
276
|
+
: reserved
|
|
277
|
+
? `${entry.quantity === 1 ? "Your ticket is" : `Your ${entry.quantity} tickets are`} reserved until ${deadline}. Nobody else can buy ${entry.quantity === 1 ? "it" : "them"}. Check out from the ticket options above.`
|
|
278
|
+
: `Your offer closes ${deadline}. Tickets are not reserved. Buy from the ticket options above to secure them.`}
|
|
259
279
|
</div>
|
|
260
280
|
)}
|
|
261
281
|
</div>
|