@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.
Files changed (74) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAnalytics.ts +59 -8
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +16 -0
  8. package/src/data/queries/useMusicLink.ts +28 -0
  9. package/src/data/queries/useMyBookings.ts +25 -0
  10. package/src/data/queries/usePaymentFlow.ts +84 -18
  11. package/src/data/queries/useShipping.ts +5 -0
  12. package/src/data/queries/useSubscriptions.ts +50 -4
  13. package/src/index.ts +6 -0
  14. package/src/server/index.ts +51 -0
  15. package/src/server/platform.ts +1 -1
  16. package/src/server/platformEvents.generated.ts +33 -33
  17. package/src/server/pwa.ts +2 -2
  18. package/src/types/models.ts +94 -5
  19. package/src/types/paystack-inline.d.ts +45 -0
  20. package/src/ui/analytics/ForgeAnalytics.tsx +15 -0
  21. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  22. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  23. package/src/ui/format/attendees.ts +2 -2
  24. package/src/ui/format/ticketAvailability.ts +226 -0
  25. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  26. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  27. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  29. package/src/ui/headless/donation/Donation.tsx +10 -0
  30. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  31. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  32. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  33. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  34. package/src/ui/headless/offer/Offer.tsx +19 -1
  35. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  36. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  37. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  38. package/src/ui/index.ts +23 -0
  39. package/src/ui/shell/diagnosticsGating.ts +3 -3
  40. package/src/ui/styled/AccountDashboard.tsx +73 -4
  41. package/src/ui/styled/AudioPlayer.tsx +1 -1
  42. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  43. package/src/ui/styled/Checkout.tsx +25 -32
  44. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  45. package/src/ui/styled/CoachingBooking.tsx +11 -2
  46. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  47. package/src/ui/styled/CohortPage.tsx +1 -1
  48. package/src/ui/styled/ContactForm.tsx +1 -1
  49. package/src/ui/styled/CourseCheckout.tsx +10 -1
  50. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  51. package/src/ui/styled/DiscountCode.tsx +1 -1
  52. package/src/ui/styled/EmailListForm.tsx +1 -1
  53. package/src/ui/styled/EventConfirmation.tsx +1 -1
  54. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  55. package/src/ui/styled/EventTickets.tsx +135 -21
  56. package/src/ui/styled/EventWaitlist.tsx +23 -3
  57. package/src/ui/styled/HoldNotice.tsx +2 -2
  58. package/src/ui/styled/InvoicePayment.tsx +14 -5
  59. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  60. package/src/ui/styled/MusicLinkPage.tsx +365 -0
  61. package/src/ui/styled/OfferButton.tsx +1 -1
  62. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  63. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  64. package/src/ui/styled/PresaleCode.tsx +1 -1
  65. package/src/ui/styled/ReviewForm.tsx +2 -2
  66. package/src/ui/styled/TicketTransfer.tsx +3 -3
  67. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  68. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  69. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  70. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  71. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  72. package/src/utils/membershipAccess.ts +3 -3
  73. package/src/utils/paystackCheckout.ts +277 -0
  74. 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: string;
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
- if ((created || checkoutId) && flow.result?.paymentSecret && flow.result?.paymentId) {
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: flow.result.paymentSecret,
357
- paymentId: flow.result.paymentId,
358
- chargedAmount: flow.result.amount ?? 0,
359
- chargedCurrency: flow.result.currency ?? "",
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({ path: `/public/donations/${donationId}/start-payment`, autoStart: false });
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`; finalize on the
15
- * return page with `useInvoiceFinalize`.
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 Stripe returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
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: `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`,
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 <>{children({ clientSecret: ctx.clientSecret, returnUrl: ctx.returnUrl, amount: ctx.totalPrice })}</>;
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 (Stripe). */
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`; finalize on the return page with `usePaymentLinkFinalize`.
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 "" when absent. */
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