@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
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ShippingCountry,
|
|
12
12
|
} from "../../../data/queries/useShipping";
|
|
13
13
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
14
|
+
import { hasPaystackSession, openPaystackCheckout as openPaystackModal } from "../../../utils/paystackCheckout";
|
|
14
15
|
import {
|
|
15
16
|
useCreateCheckout,
|
|
16
17
|
useApplyCheckoutCoupon,
|
|
@@ -68,8 +69,17 @@ interface AppliedCoupon {
|
|
|
68
69
|
discountAmount?: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The charge the payment stage is currently pointed at.
|
|
74
|
+
*
|
|
75
|
+
* Exactly one provider session is populated: `paymentSecret` on Stripe,
|
|
76
|
+
* `accessCode` + `checkoutUrl` on Paystack. Nothing here may assume a secret is
|
|
77
|
+
* present. A Paystack charge has never had one.
|
|
78
|
+
*/
|
|
71
79
|
interface CouponPaymentUpdate {
|
|
72
|
-
paymentSecret
|
|
80
|
+
paymentSecret?: string;
|
|
81
|
+
accessCode?: string;
|
|
82
|
+
checkoutUrl?: string;
|
|
73
83
|
paymentId: string;
|
|
74
84
|
chargedAmount: number;
|
|
75
85
|
chargedCurrency: string;
|
|
@@ -287,6 +297,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
287
297
|
body: checkoutId ? { checkoutId } : { orderId: created?.orderId },
|
|
288
298
|
returnUrl,
|
|
289
299
|
enabled: !!checkoutId || !!created?.orderId,
|
|
300
|
+
// The cart is the one surface where the buyer is looking at a summary they
|
|
301
|
+
// may still want to change (a discount code, a shipping rate). Opening the
|
|
302
|
+
// Paystack modal over it the instant the intent lands would cover that up,
|
|
303
|
+
// so the pay button drives it here, via `openPaystackCheckout` below.
|
|
304
|
+
paystackRedirect: false,
|
|
290
305
|
});
|
|
291
306
|
|
|
292
307
|
const orderId = created?.orderId ?? null;
|
|
@@ -351,12 +366,19 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
351
366
|
// `checkoutId` as well as `created`: a BUNDLE has no `created` order, and
|
|
352
367
|
// gating on that alone left a bundle buyer with no payment element at all
|
|
353
368
|
// unless a coupon happened to re-mint one.
|
|
354
|
-
|
|
369
|
+
const started = flow.result;
|
|
370
|
+
// Stripe answers with a secret, Paystack with a session. Either one means
|
|
371
|
+
// there is a charge to pay. Gating on the secret alone left every Paystack
|
|
372
|
+
// buyer with no payment step at all.
|
|
373
|
+
const hasSession = !!(started?.paymentSecret || started?.accessCode || started?.checkoutUrl);
|
|
374
|
+
if ((created || checkoutId) && started && hasSession && started.paymentId) {
|
|
355
375
|
return {
|
|
356
|
-
paymentSecret:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
376
|
+
paymentSecret: started.paymentSecret,
|
|
377
|
+
accessCode: started.accessCode,
|
|
378
|
+
checkoutUrl: started.checkoutUrl,
|
|
379
|
+
paymentId: started.paymentId,
|
|
380
|
+
chargedAmount: started.amount ?? 0,
|
|
381
|
+
chargedCurrency: started.currency ?? "",
|
|
360
382
|
};
|
|
361
383
|
}
|
|
362
384
|
return null;
|
|
@@ -496,9 +518,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
496
518
|
setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
|
|
497
519
|
setCouponTaxQuote(data.taxQuote ?? null);
|
|
498
520
|
setCouponPaymentUpdate(
|
|
499
|
-
data.paymentSecret && data.paymentId
|
|
521
|
+
(data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId
|
|
500
522
|
? {
|
|
501
523
|
paymentSecret: data.paymentSecret,
|
|
524
|
+
accessCode: data.accessCode,
|
|
525
|
+
checkoutUrl: data.checkoutUrl,
|
|
502
526
|
paymentId: data.paymentId,
|
|
503
527
|
chargedAmount: data.chargedAmount,
|
|
504
528
|
chargedCurrency: data.chargedCurrency,
|
|
@@ -553,9 +577,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
553
577
|
setDiscountAmount(0);
|
|
554
578
|
setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
|
|
555
579
|
setCouponTaxQuote(data.taxQuote ?? null);
|
|
556
|
-
if (data.paymentSecret && data.paymentId) {
|
|
580
|
+
if ((data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId) {
|
|
557
581
|
setCouponPaymentUpdate({
|
|
558
582
|
paymentSecret: data.paymentSecret,
|
|
583
|
+
accessCode: data.accessCode,
|
|
584
|
+
checkoutUrl: data.checkoutUrl,
|
|
559
585
|
paymentId: data.paymentId,
|
|
560
586
|
chargedAmount: data.chargedAmount,
|
|
561
587
|
chargedCurrency: data.chargedCurrency,
|
|
@@ -598,6 +624,36 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
598
624
|
|
|
599
625
|
const provider = flow.provider;
|
|
600
626
|
|
|
627
|
+
// ── Paystack inline checkout ────────────────────────────────────────────────
|
|
628
|
+
/**
|
|
629
|
+
* The Paystack session the pay button acts on.
|
|
630
|
+
*
|
|
631
|
+
* Read off `effectivePayment` rather than `flow.result` so a discount code
|
|
632
|
+
* that re-mints the charge is what gets opened. Opening the start-payment
|
|
633
|
+
* session after a coupon applied would charge the undiscounted amount.
|
|
634
|
+
*/
|
|
635
|
+
const paystackSession = useMemo(
|
|
636
|
+
() => ({
|
|
637
|
+
accessCode: effectivePayment?.accessCode,
|
|
638
|
+
checkoutUrl: effectivePayment?.checkoutUrl,
|
|
639
|
+
returnUrl,
|
|
640
|
+
}),
|
|
641
|
+
[effectivePayment?.accessCode, effectivePayment?.checkoutUrl, returnUrl],
|
|
642
|
+
);
|
|
643
|
+
const canOpenPaystack = hasPaystackSession(paystackSession);
|
|
644
|
+
|
|
645
|
+
const openPaystackCheckout = useCallback(
|
|
646
|
+
() =>
|
|
647
|
+
openPaystackModal(paystackSession, {
|
|
648
|
+
// Closing the popup is not a failed order. The order is still there,
|
|
649
|
+
// the reservation is still running, and the button opens the very same
|
|
650
|
+
// session again, so nothing is said and nothing is marked.
|
|
651
|
+
onDismiss: () => {},
|
|
652
|
+
onError: (message) => setPageError(message || "The payment could not be completed. Please try again."),
|
|
653
|
+
}),
|
|
654
|
+
[paystackSession],
|
|
655
|
+
);
|
|
656
|
+
|
|
601
657
|
return {
|
|
602
658
|
// ── Cart + totals ────────────────────────────────────────────────────────
|
|
603
659
|
cartItems,
|
|
@@ -663,6 +719,14 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
663
719
|
isRetryingHold: flow.isStarting,
|
|
664
720
|
provider,
|
|
665
721
|
isPaystack: provider === PaymentProviderName.Paystack,
|
|
722
|
+
/**
|
|
723
|
+
* Open the Paystack modal for whatever charge is CURRENTLY live. A coupon
|
|
724
|
+
* re-mints the session, and the pay button has to follow it rather than
|
|
725
|
+
* re-opening the pre-discount one.
|
|
726
|
+
*/
|
|
727
|
+
openPaystackCheckout,
|
|
728
|
+
/** True once there is a Paystack session the pay button can open. */
|
|
729
|
+
canOpenPaystack,
|
|
666
730
|
paymentSecret: effectivePayment?.paymentSecret,
|
|
667
731
|
paymentId: effectivePayment?.paymentId,
|
|
668
732
|
chargedAmount: effectivePayment?.chargedAmount ?? 0,
|
|
@@ -297,6 +297,10 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
297
297
|
setQuestionnaire,
|
|
298
298
|
continueToPayment,
|
|
299
299
|
clientSecret: flow.clientSecret,
|
|
300
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
301
|
+
isPaystack: flow.isPaystack,
|
|
302
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
303
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
300
304
|
// The booking quote WINS over start-payment: every `continueToPayment`
|
|
301
305
|
// re-quotes before starting the payment, and applying or removing a code
|
|
302
306
|
// re-quotes again — so the quote is never staler, and preferring the
|
|
@@ -193,6 +193,10 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
|
|
|
193
193
|
setQuestionnaire,
|
|
194
194
|
continueToPayment,
|
|
195
195
|
clientSecret: flow.clientSecret,
|
|
196
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
197
|
+
isPaystack: flow.isPaystack,
|
|
198
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
199
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
196
200
|
// The booking quote WINS over start-payment: every `continueToPayment`
|
|
197
201
|
// re-quotes before starting the payment, and applying or removing a code
|
|
198
202
|
// re-quotes again — so the quote is never staler, and preferring the
|
|
@@ -13,15 +13,22 @@ import {
|
|
|
13
13
|
useDonationContext,
|
|
14
14
|
type DonationProviderProps,
|
|
15
15
|
} from "./DonationContext";
|
|
16
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
16
17
|
|
|
17
18
|
/** Render-prop payload for `<Donation.PaymentStep>`. The app supplies the
|
|
18
19
|
* actual payment UI (e.g. Stripe Elements) — the SDK owns only the data. */
|
|
19
20
|
export interface DonationPaymentRenderProps {
|
|
21
|
+
/** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
|
|
20
22
|
clientSecret?: string;
|
|
21
23
|
returnUrl: string;
|
|
22
24
|
amount: number;
|
|
23
25
|
onSucceeded: () => void;
|
|
24
26
|
onFailed: (message?: string) => void;
|
|
27
|
+
/** PAYSTACK: render a pay button instead of a card form. */
|
|
28
|
+
isPaystack: boolean;
|
|
29
|
+
canOpenPaystack: boolean;
|
|
30
|
+
/** Opens (or re-opens, after a dismissal) the Paystack modal. */
|
|
31
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
type DonationComponent = ((props: DonationProviderProps) => React.ReactElement) & {
|
|
@@ -102,6 +109,9 @@ Donation.PaymentStep = function DonationPaymentStep({ children }) {
|
|
|
102
109
|
amount: ctx.startResult?.totalAmount ?? ctx.estimatedTotal,
|
|
103
110
|
onSucceeded: ctx.onPaymentSucceeded,
|
|
104
111
|
onFailed: ctx.onPaymentFailed,
|
|
112
|
+
isPaystack: ctx.isPaystack,
|
|
113
|
+
canOpenPaystack: ctx.canOpenPaystack,
|
|
114
|
+
openPaystackCheckout: ctx.openPaystackCheckout,
|
|
105
115
|
})}
|
|
106
116
|
</>
|
|
107
117
|
);
|
|
@@ -3,6 +3,7 @@ import type { PaymentFlowResult, PublicDonation } from "../../../types/models";
|
|
|
3
3
|
import { useGetDonation, useTrackDonationView } from "../../../data/queries/useDonations";
|
|
4
4
|
import { useDonationFinalize } from "../../../data/queries/useFinalize";
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
6
7
|
import { estimateProcessingFee } from "./fees";
|
|
7
8
|
|
|
8
9
|
export type DonationStep = "amount" | "payment" | "result";
|
|
@@ -46,6 +47,15 @@ export interface DonationContextValue {
|
|
|
46
47
|
/** The return URL passed to the payment provider. */
|
|
47
48
|
returnUrl: string;
|
|
48
49
|
continueToPayment: () => Promise<void>;
|
|
50
|
+
/** True once the charge started on Paystack rather than Stripe. */
|
|
51
|
+
isPaystack: boolean;
|
|
52
|
+
/** True once there is a Paystack session a pay button can (re-)open. */
|
|
53
|
+
canOpenPaystack: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Re-open the Paystack modal on the SAME charge. The donor who closed it by
|
|
56
|
+
* accident has not failed and needs a way back in.
|
|
57
|
+
*/
|
|
58
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
49
59
|
|
|
50
60
|
// Result
|
|
51
61
|
outcome: DonationOutcome;
|
|
@@ -75,7 +85,20 @@ export interface DonationProviderProps {
|
|
|
75
85
|
export function DonationProvider({ donationId, children, returnUrl, onSuccess }: DonationProviderProps) {
|
|
76
86
|
const { data: donation, isLoading } = useGetDonation(donationId);
|
|
77
87
|
const trackView = useTrackDonationView();
|
|
78
|
-
const flow = usePaymentFlow({
|
|
88
|
+
const flow = usePaymentFlow({
|
|
89
|
+
path: `/public/donations/${donationId}/start-payment`,
|
|
90
|
+
autoStart: false,
|
|
91
|
+
// The donation modal finalizes IN PLACE, so a Paystack success stays here
|
|
92
|
+
// and runs the same finalize the Stripe path runs. Without this the helper
|
|
93
|
+
// would navigate to `returnUrl` and the donor would lose the thank-you step.
|
|
94
|
+
onPaystackSuccess: () => {
|
|
95
|
+
void onPaymentSucceeded();
|
|
96
|
+
},
|
|
97
|
+
// Closing the popup is not a failed donation: the payment step stays put,
|
|
98
|
+
// with the pay button live again on the very same charge.
|
|
99
|
+
onPaystackDismiss: () => {},
|
|
100
|
+
onPaystackError: (message) => onPaymentFailed(message),
|
|
101
|
+
});
|
|
79
102
|
const donationFinalize = useDonationFinalize();
|
|
80
103
|
|
|
81
104
|
const [open, setOpenState] = useState(false);
|
|
@@ -212,6 +235,9 @@ export function DonationProvider({ donationId, children, returnUrl, onSuccess }:
|
|
|
212
235
|
startResult: flow.result,
|
|
213
236
|
returnUrl: resolvedReturnUrl,
|
|
214
237
|
continueToPayment,
|
|
238
|
+
isPaystack: flow.isPaystack,
|
|
239
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
240
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
215
241
|
outcome,
|
|
216
242
|
paymentErrorMessage,
|
|
217
243
|
onPaymentSucceeded,
|
|
@@ -5,6 +5,7 @@ import { useCreateEventOrder } from "../../../data/queries/useEvents";
|
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
6
|
import { useCouponField } from "../coupon/useCouponField";
|
|
7
7
|
import { usePresaleCode } from "./usePresaleCode";
|
|
8
|
+
import { useEventWaitlistClaim } from "../../../data/queries/useEventWaitlist";
|
|
8
9
|
import {
|
|
9
10
|
buildAttendeeSlots,
|
|
10
11
|
setAttendeeName,
|
|
@@ -58,6 +59,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
58
59
|
const { event, isLoading } = presale;
|
|
59
60
|
const eventId = event?.id;
|
|
60
61
|
const createOrder = useCreateEventOrder(eventId);
|
|
62
|
+
/**
|
|
63
|
+
* Events 2.6b — the waitlist offer this visitor arrived holding, if any.
|
|
64
|
+
*
|
|
65
|
+
* Read here rather than passed in because the whole flow needs it: the tier
|
|
66
|
+
* the offer covers must be ADDABLE (the offeree's own reservation is what
|
|
67
|
+
* makes it read sold out), and the order must carry the token that spends the
|
|
68
|
+
* reservation instead of trying to take a new one. Null for every ordinary
|
|
69
|
+
* visitor, and no request is made for them.
|
|
70
|
+
*/
|
|
71
|
+
const waitlistClaim = useEventWaitlistClaim(eventId);
|
|
72
|
+
/**
|
|
73
|
+
* Seats of `held` that belong to THIS visitor, by tier.
|
|
74
|
+
*
|
|
75
|
+
* Handed to `maxAddableTickets` / `ticketSeatState` so the picker lets the
|
|
76
|
+
* offeree add exactly what was reserved for them and not one seat more. The
|
|
77
|
+
* server re-derives the same number from the hold it issued, so a tampered
|
|
78
|
+
* value buys nothing.
|
|
79
|
+
*/
|
|
80
|
+
const reservedForYou = useMemo<Record<string, number>>(
|
|
81
|
+
() =>
|
|
82
|
+
waitlistClaim.isClaimable && waitlistClaim.entry
|
|
83
|
+
? { [waitlistClaim.entry.eventTicketId]: waitlistClaim.entry.quantity }
|
|
84
|
+
: {},
|
|
85
|
+
[waitlistClaim.isClaimable, waitlistClaim.entry],
|
|
86
|
+
);
|
|
61
87
|
const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
|
|
62
88
|
|
|
63
89
|
const [step, setStep] = useState<EventCheckoutStep>("tickets");
|
|
@@ -280,6 +306,26 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
280
306
|
setError("Please enter your name and a valid email.");
|
|
281
307
|
return;
|
|
282
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Spend the reservation only when this order IS the claim.
|
|
311
|
+
*
|
|
312
|
+
* The offered tier, alone in the cart, at no more than the offered
|
|
313
|
+
* quantity — the same shape `resolveWaitlistClaim` accepts. Checking it
|
|
314
|
+
* here rather than sending the token blindly means a fan who adds a second
|
|
315
|
+
* tier gets an ordinary (and possibly successful) purchase instead of a
|
|
316
|
+
* claim rejection, which is the friendlier of the two truths.
|
|
317
|
+
*/
|
|
318
|
+
const claimedTierId = waitlistClaim.entry?.eventTicketId;
|
|
319
|
+
const cartTiers = Object.keys(selectedTickets).filter((id) => (selectedTickets[id] ?? 0) > 0);
|
|
320
|
+
const waitlistClaimToken =
|
|
321
|
+
waitlistClaim.isClaimable &&
|
|
322
|
+
claimedTierId &&
|
|
323
|
+
cartTiers.length === 1 &&
|
|
324
|
+
cartTiers[0] === claimedTierId &&
|
|
325
|
+
(selectedTickets[claimedTierId] ?? 0) <= (waitlistClaim.entry?.quantity ?? 0)
|
|
326
|
+
? waitlistClaim.token
|
|
327
|
+
: null;
|
|
328
|
+
|
|
283
329
|
try {
|
|
284
330
|
const data = await createOrder.mutateAsync({
|
|
285
331
|
items: selectedTickets,
|
|
@@ -301,6 +347,16 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
301
347
|
// the code they already typed. Omitted when there is none, so an
|
|
302
348
|
// ordinary purchase posts the body it always did.
|
|
303
349
|
...(presale.code ? { accessCode: presale.code } : {}),
|
|
350
|
+
/**
|
|
351
|
+
* The waitlist claim (2.6b). Sent ONLY when the cart is exactly the
|
|
352
|
+
* offered tier: the server refuses a claim whose cart reaches past it,
|
|
353
|
+
* and sending one anyway would turn "you also added a T-shirt tier"
|
|
354
|
+
* into a claim failure rather than an ordinary purchase.
|
|
355
|
+
*
|
|
356
|
+
* Omitted entirely otherwise, so an ordinary purchase posts the body it
|
|
357
|
+
* always did.
|
|
358
|
+
*/
|
|
359
|
+
...(waitlistClaimToken ? { waitlistClaimToken } : {}),
|
|
304
360
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
305
361
|
...(readLanding() ?? {}),
|
|
306
362
|
});
|
|
@@ -477,6 +533,16 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
477
533
|
* event without a coded tier, so a surface can render it unconditionally.
|
|
478
534
|
*/
|
|
479
535
|
presale,
|
|
536
|
+
/**
|
|
537
|
+
* Events 2.6b — the waitlist offer this visitor arrived holding.
|
|
538
|
+
*
|
|
539
|
+
* `reservedForYou` is the per-tier seat count a surface must pass to
|
|
540
|
+
* `maxAddableTickets` / `ticketSeatState`, or the offeree cannot add the
|
|
541
|
+
* seat that is being held for them. `waitlistClaim.isClaimable` is what a
|
|
542
|
+
* surface renders "your tickets are reserved until…" off.
|
|
543
|
+
*/
|
|
544
|
+
waitlistClaim,
|
|
545
|
+
reservedForYou,
|
|
480
546
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
481
547
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
482
548
|
firstName,
|
|
@@ -493,6 +559,10 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
493
559
|
ticketsInCart: event ? hasTicketsFor(event.id) : false,
|
|
494
560
|
continueToPayment,
|
|
495
561
|
clientSecret: flow.clientSecret,
|
|
562
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
563
|
+
isPaystack: flow.isPaystack,
|
|
564
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
565
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
496
566
|
returnUrl,
|
|
497
567
|
isProcessing: createOrder.isPending || flow.isStarting,
|
|
498
568
|
error,
|
|
@@ -11,8 +11,9 @@ export interface UseInvoicePaymentOptions {
|
|
|
11
11
|
/**
|
|
12
12
|
* Headless invoice payment: loads the invoice, starts its payment, and exposes
|
|
13
13
|
* the PDF download. Composes `useInvoice`, `useDownloadInvoicePdf`,
|
|
14
|
-
* `usePaymentFlow`. Stripe UI renders from `clientSecret`;
|
|
15
|
-
*
|
|
14
|
+
* `usePaymentFlow`. Stripe UI renders from `clientSecret`; Paystack opens its
|
|
15
|
+
* inline modal (and `openPaystackCheckout` re-opens it after a dismissal).
|
|
16
|
+
* Finalize on the return page with `useInvoiceFinalize`.
|
|
16
17
|
*/
|
|
17
18
|
export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOptions = {}) {
|
|
18
19
|
const invoice = useInvoice(invoiceId);
|
|
@@ -35,6 +36,10 @@ export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOpt
|
|
|
35
36
|
clientSecret: flow.clientSecret,
|
|
36
37
|
paymentId: flow.paymentId,
|
|
37
38
|
provider: flow.provider,
|
|
39
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
40
|
+
isPaystack: flow.isPaystack,
|
|
41
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
42
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
38
43
|
/** Sales-tax quote (built from the invoice's own tax columns — no re-quote). */
|
|
39
44
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
40
45
|
isStarting: flow.isStarting,
|
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
} from "../../../data/queries/useSubscriptions";
|
|
9
9
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
10
10
|
import { readLanding } from "../../../utils/landing";
|
|
11
|
+
import {
|
|
12
|
+
hasPaystackSession,
|
|
13
|
+
openPaystackCheckout as openPaystackModal,
|
|
14
|
+
type PaystackCheckoutOutcome,
|
|
15
|
+
type PaystackCheckoutSession,
|
|
16
|
+
} from "../../../utils/paystackCheckout";
|
|
11
17
|
|
|
12
18
|
export type MembershipCheckoutStep = "select" | "payment";
|
|
13
19
|
export type BillingCycle = "month" | "year";
|
|
@@ -15,7 +21,7 @@ export type BillingCycle = "month" | "year";
|
|
|
15
21
|
export interface UseMembershipCheckoutOptions {
|
|
16
22
|
/** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
|
|
17
23
|
initialTierId?: string;
|
|
18
|
-
/** Where
|
|
24
|
+
/** Where the provider returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
|
|
19
25
|
returnPath?: string;
|
|
20
26
|
/** Where free activations land. Default `/i/account?tab=membership`. */
|
|
21
27
|
freeReturnPath?: string;
|
|
@@ -35,6 +41,11 @@ const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; pric
|
|
|
35
41
|
* subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
|
|
36
42
|
* `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
|
|
37
43
|
* Paid flow exposes `clientSecret` for `ForgePaymentProvider`.
|
|
44
|
+
*
|
|
45
|
+
* Three outcomes, not two: a member who ALREADY subscribes to this artist is
|
|
46
|
+
* changing tier, which the server settles by proration on their existing
|
|
47
|
+
* subscription. That completes server-side with no payment step — it is
|
|
48
|
+
* finished the moment `subscribe()` resolves, exactly like a free activation.
|
|
38
49
|
*/
|
|
39
50
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
40
51
|
const { user } = usePublicAuth();
|
|
@@ -48,6 +59,8 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
48
59
|
const [billingCycle, setBillingCycle] = useState<BillingCycle>("month");
|
|
49
60
|
const [customAmount, setCustomAmount] = useState<number>(0);
|
|
50
61
|
const [clientSecret, setClientSecret] = useState<string | undefined>();
|
|
62
|
+
/** The started Paystack checkout, kept so the modal can be re-opened. */
|
|
63
|
+
const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
|
|
51
64
|
const [error, setError] = useState<string | null>(null);
|
|
52
65
|
|
|
53
66
|
const selectedTier = useMemo(
|
|
@@ -72,6 +85,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
72
85
|
return;
|
|
73
86
|
}
|
|
74
87
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
88
|
+
const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
|
|
75
89
|
const attributionRefId = readAttributionRef() ?? undefined;
|
|
76
90
|
const landing = readLanding() ?? {};
|
|
77
91
|
try {
|
|
@@ -87,10 +101,46 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
87
101
|
amount,
|
|
88
102
|
billingCycle,
|
|
89
103
|
membershipTierId: selectedTier.id,
|
|
90
|
-
returnUrl
|
|
104
|
+
returnUrl,
|
|
91
105
|
attributionRefId,
|
|
92
106
|
...landing,
|
|
93
107
|
});
|
|
108
|
+
|
|
109
|
+
// A member who already subscribes is CHANGING TIER: the server moved their
|
|
110
|
+
// existing subscription onto the new price with proration, so there is
|
|
111
|
+
// nothing to pay and no card to collect. Sending them to a payment step
|
|
112
|
+
// with no client secret is a form that can never be completed.
|
|
113
|
+
// Every provider session counts, not just Stripe's secret. Reading a
|
|
114
|
+
// missing `clientSecret` as "nothing to pay" would activate a Paystack
|
|
115
|
+
// membership nobody has paid for.
|
|
116
|
+
const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
|
|
117
|
+
if (!requiresPayment) {
|
|
118
|
+
if (opts.onComplete) opts.onComplete();
|
|
119
|
+
else if (typeof window !== "undefined") {
|
|
120
|
+
window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (data.accessCode || data.checkoutUrl) {
|
|
126
|
+
// Paystack: the fan pays in a modal over this page. The helper navigates
|
|
127
|
+
// to `returnUrl` on success, the same return leg the hosted redirect
|
|
128
|
+
// used, so `confirmLatest` still reconciles on arrival.
|
|
129
|
+
const session: PaystackCheckoutSession = {
|
|
130
|
+
accessCode: data.accessCode,
|
|
131
|
+
checkoutUrl: data.checkoutUrl,
|
|
132
|
+
returnUrl,
|
|
133
|
+
};
|
|
134
|
+
setPaystackSession(session);
|
|
135
|
+
setStep("payment");
|
|
136
|
+
void openPaystackModal(session, {
|
|
137
|
+
// Closing the popup leaves the subscription unpaid and re-openable.
|
|
138
|
+
onDismiss: () => {},
|
|
139
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
setClientSecret(data.clientSecret);
|
|
95
145
|
setStep("payment");
|
|
96
146
|
} catch (e) {
|
|
@@ -113,6 +163,17 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
113
163
|
amount,
|
|
114
164
|
subscribe,
|
|
115
165
|
clientSecret,
|
|
166
|
+
/** True once the subscription started on Paystack rather than Stripe. */
|
|
167
|
+
isPaystack: !!paystackSession,
|
|
168
|
+
canOpenPaystack: hasPaystackSession(paystackSession),
|
|
169
|
+
/** Re-open the Paystack modal on the same subscription charge. */
|
|
170
|
+
openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
|
|
171
|
+
paystackSession
|
|
172
|
+
? openPaystackModal(paystackSession, {
|
|
173
|
+
onDismiss: () => {},
|
|
174
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
175
|
+
})
|
|
176
|
+
: Promise.resolve("unavailable" as PaystackCheckoutOutcome),
|
|
116
177
|
isProcessing: createPaid.isPending || createFree.isPending,
|
|
117
178
|
error,
|
|
118
179
|
/** Call on the return page to reconcile the latest subscription. */
|
|
@@ -9,12 +9,19 @@ import {
|
|
|
9
9
|
DialogTitle,
|
|
10
10
|
} from "../dialog";
|
|
11
11
|
import { OfferProvider, useOfferContext, type OfferProviderProps } from "./OfferContext";
|
|
12
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
12
13
|
|
|
13
14
|
/** Render-prop payload for `<Offer.PaymentStep>` (redirect-mode payment). */
|
|
14
15
|
export interface OfferPaymentRenderProps {
|
|
16
|
+
/** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
|
|
15
17
|
clientSecret?: string;
|
|
16
18
|
returnUrl: string;
|
|
17
19
|
amount: number;
|
|
20
|
+
/** PAYSTACK: render a pay button instead of a card form. */
|
|
21
|
+
isPaystack: boolean;
|
|
22
|
+
canOpenPaystack: boolean;
|
|
23
|
+
/** Opens (or re-opens, after a dismissal) the Paystack modal. */
|
|
24
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
type OfferComponent = ((props: OfferProviderProps) => React.ReactElement) & {
|
|
@@ -82,7 +89,18 @@ Offer.PaymentStep = function OfferPaymentStep({ children }) {
|
|
|
82
89
|
const ctx = useOfferContext();
|
|
83
90
|
if (ctx.step !== "payment") return null;
|
|
84
91
|
if (typeof children === "function") {
|
|
85
|
-
return
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{children({
|
|
95
|
+
clientSecret: ctx.clientSecret,
|
|
96
|
+
returnUrl: ctx.returnUrl,
|
|
97
|
+
amount: ctx.totalPrice,
|
|
98
|
+
isPaystack: ctx.isPaystack,
|
|
99
|
+
canOpenPaystack: ctx.canOpenPaystack,
|
|
100
|
+
openPaystackCheckout: ctx.openPaystackCheckout,
|
|
101
|
+
})}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
86
104
|
}
|
|
87
105
|
return <>{children}</>;
|
|
88
106
|
};
|
|
@@ -5,6 +5,7 @@ import { useCreateOrder } from "../../../data/queries/useOrders";
|
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
6
|
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
7
7
|
import { useInventoryHold, type InventoryHoldState } from "../checkout/useInventoryHold";
|
|
8
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
8
9
|
|
|
9
10
|
export type OfferStep = "details" | "payment";
|
|
10
11
|
|
|
@@ -36,8 +37,17 @@ export interface OfferContextValue {
|
|
|
36
37
|
|
|
37
38
|
isProcessing: boolean;
|
|
38
39
|
error: string | null;
|
|
39
|
-
/** Payment intent once the order is created (
|
|
40
|
+
/** Payment intent once the order is created (STRIPE only). */
|
|
40
41
|
clientSecret?: string;
|
|
42
|
+
/** True once the charge started on Paystack rather than Stripe. */
|
|
43
|
+
isPaystack: boolean;
|
|
44
|
+
/** True once there is a Paystack session a pay button can (re-)open. */
|
|
45
|
+
canOpenPaystack: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Re-open the Paystack modal on the SAME charge. A buyer who closed it has
|
|
48
|
+
* not failed, and the order is still sitting there waiting to be paid.
|
|
49
|
+
*/
|
|
50
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
41
51
|
returnUrl: string;
|
|
42
52
|
continueToPayment: () => Promise<void>;
|
|
43
53
|
/** The stock reservation held while the buyer pays. All-null when none was taken. */
|
|
@@ -205,6 +215,9 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
205
215
|
isProcessing: createOrder.isPending || flow.isStarting,
|
|
206
216
|
error,
|
|
207
217
|
clientSecret: flow.result?.paymentSecret,
|
|
218
|
+
isPaystack: flow.isPaystack,
|
|
219
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
220
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
208
221
|
returnUrl,
|
|
209
222
|
continueToPayment,
|
|
210
223
|
hold,
|
|
@@ -11,7 +11,9 @@ export interface UsePaymentLinkPaymentOptions {
|
|
|
11
11
|
/**
|
|
12
12
|
* Headless payment-link checkout: loads the link and starts its payment.
|
|
13
13
|
* Composes `usePaymentLink` + `usePaymentFlow`. Stripe UI renders from
|
|
14
|
-
* `clientSecret`;
|
|
14
|
+
* `clientSecret`; Paystack opens its inline modal (and `openPaystackCheckout`
|
|
15
|
+
* re-opens it after a dismissal). Finalize on the return page with
|
|
16
|
+
* `usePaymentLinkFinalize`.
|
|
15
17
|
*/
|
|
16
18
|
export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLinkPaymentOptions = {}) {
|
|
17
19
|
const link = usePaymentLink(paymentLinkId);
|
|
@@ -34,6 +36,10 @@ export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLi
|
|
|
34
36
|
clientSecret: flow.clientSecret,
|
|
35
37
|
paymentId: flow.paymentId,
|
|
36
38
|
provider: flow.provider,
|
|
39
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
40
|
+
isPaystack: flow.isPaystack,
|
|
41
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
42
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
37
43
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
38
44
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
39
45
|
isStarting: flow.isStarting,
|
|
@@ -343,10 +343,10 @@ export function formatWorkMoney(amount: number, currency = "USD"): string {
|
|
|
343
343
|
return `${symbol}${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
/** ISO date → short human date, or "
|
|
346
|
+
/** ISO date → short human date, or "Not set" when absent. */
|
|
347
347
|
export function formatWorkDate(value: string | null | undefined): string {
|
|
348
|
-
if (!value) return "
|
|
348
|
+
if (!value) return "Not set";
|
|
349
349
|
const d = new Date(value);
|
|
350
|
-
if (Number.isNaN(d.getTime())) return "
|
|
350
|
+
if (Number.isNaN(d.getTime())) return "Not set";
|
|
351
351
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
352
352
|
}
|
package/src/ui/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
|
|
|
64
64
|
export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
|
|
65
65
|
export { BlogComments, type BlogCommentsProps } from "./styled/BlogComments";
|
|
66
66
|
export { PageActions, type PageActionsProps } from "./styled/PageActions";
|
|
67
|
+
export { MusicLinkPage, type MusicLinkPageProps } from "./styled/MusicLinkPage";
|
|
67
68
|
export { BlogPostFooter, type BlogPostFooterProps } from "./styled/BlogPostFooter";
|
|
68
69
|
export { BotProtection, EMPTY_BOT_FIELDS, type BotFields } from "./styled/BotProtection";
|
|
69
70
|
export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
|
|
@@ -112,6 +113,7 @@ export { CookieConsent, type CookieConsentProps } from "./styled/CookieConsent";
|
|
|
112
113
|
export { Loading, type LoadingProps } from "./styled/Loading";
|
|
113
114
|
/** The inventory-hold countdown + its lapsed/409 state. See `useInventoryHold`. */
|
|
114
115
|
export { HoldNotice, type HoldNoticeProps } from "./styled/HoldNotice";
|
|
116
|
+
export { PaystackPayButton, type PaystackPayButtonProps } from "./styled/PaystackPayButton";
|
|
115
117
|
export {
|
|
116
118
|
ConfirmationStage,
|
|
117
119
|
ConfirmationCard,
|
|
@@ -173,6 +175,27 @@ export {
|
|
|
173
175
|
resolveUnitPrice,
|
|
174
176
|
ticketSubtotals,
|
|
175
177
|
} from "./format/pwyw";
|
|
178
|
+
// When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
|
|
179
|
+
// close a "discoverable only by failing" journey: `ticketSeatsAvailable`
|
|
180
|
+
// subtracts `held` the way the server does, so a fan is never shown seats that
|
|
181
|
+
// are already in someone else's checkout; `ticketSaleWindow` surfaces a
|
|
182
|
+
// scheduled on-sale, which shipped enforced and drawn nowhere. Presentation
|
|
183
|
+
// only — `createOrder` re-checks both.
|
|
184
|
+
export {
|
|
185
|
+
ticketSeatsAvailable,
|
|
186
|
+
ticketSeatsUnsold,
|
|
187
|
+
ticketSeatState,
|
|
188
|
+
ticketSeatLabel,
|
|
189
|
+
ticketSeatNotice,
|
|
190
|
+
ticketSaleWindow,
|
|
191
|
+
isTicketScheduled,
|
|
192
|
+
formatOnSaleAt,
|
|
193
|
+
onSaleBadge,
|
|
194
|
+
onSaleNotice,
|
|
195
|
+
maxAddableTickets,
|
|
196
|
+
type TicketSeatState,
|
|
197
|
+
type TicketSaleWindow,
|
|
198
|
+
} from "./format/ticketAvailability";
|
|
176
199
|
// Per-attendee names at checkout. Exported for a site rendering its own ticket
|
|
177
200
|
// UI — and used by `apps/client`, which has its own event checkout: the request
|
|
178
201
|
// shape is positional per tier and a length mismatch is refused server-side, so
|