@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.
Files changed (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  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 +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. 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,
@@ -18,6 +19,8 @@ import {
18
19
  type ApplyCheckoutCouponResult,
19
20
  } from "../../../data/queries/useCheckouts";
20
21
  import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
22
+ import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
23
+ import { useInventoryHold } from "./useInventoryHold";
21
24
  import { readAttributionRef } from "../../../utils/attribution";
22
25
  import { readLanding } from "../../../utils/landing";
23
26
  import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
@@ -66,8 +69,17 @@ interface AppliedCoupon {
66
69
  discountAmount?: number;
67
70
  }
68
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
+ */
69
79
  interface CouponPaymentUpdate {
70
- paymentSecret: string;
80
+ paymentSecret?: string;
81
+ accessCode?: string;
82
+ checkoutUrl?: string;
71
83
  paymentId: string;
72
84
  chargedAmount: number;
73
85
  chargedCurrency: string;
@@ -285,10 +297,67 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
285
297
  body: checkoutId ? { checkoutId } : { orderId: created?.orderId },
286
298
  returnUrl,
287
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,
288
305
  });
289
306
 
290
307
  const orderId = created?.orderId ?? null;
291
308
 
309
+ // ── Inventory hold ──────────────────────────────────────────────────────────
310
+ /**
311
+ * The reservation clock the buyer is counting down.
312
+ *
313
+ * `start-payment` RESTARTS the hold and reports the new instant, so its figure
314
+ * supersedes the one `createOrder` returned — that one was measured before the
315
+ * buyer had even seen a payment field. The create-time value bridges the
316
+ * moment in between, which for a slow `start-payment` is the whole of the
317
+ * buyer's first impression of the page.
318
+ *
319
+ * A bundle has no create-time figure at all (`POST /public/checkouts` takes
320
+ * the holds but does not report a window), so it is start-payment or nothing —
321
+ * which is fine, because a bundle always starts payment immediately.
322
+ *
323
+ * `undefined` on an older API and `null` whenever no hold was taken; both mean
324
+ * "render nothing", which is exactly today's behaviour.
325
+ */
326
+ const holdExpiresAt = flow.result?.holdExpiresAt ?? created?.holdExpiresAt ?? null;
327
+ const hold = useInventoryHold(holdExpiresAt);
328
+
329
+ /**
330
+ * The reservation ran out before the payment landed (409
331
+ * `INVENTORY_HOLD_EXPIRED`), which is NOT sold-out — the items may well still
332
+ * be there, and re-running `start-payment` re-acquires the very same units
333
+ * when they are. Told apart from every other start failure so the buyer is
334
+ * offered the retry that actually works instead of a dead end.
335
+ */
336
+ const isHoldExpired = isHoldExpiredError(flow.error);
337
+ const holdExpiredError = isHoldExpired
338
+ ? holdExpiredMessage(flow.error, "Your basket reservation expired before payment finished. Please try again.")
339
+ : "";
340
+
341
+ /**
342
+ * Take the reservation again on the SAME order or bundle.
343
+ *
344
+ * The cheapest recovery there is: nothing about the cart has changed, so if
345
+ * the units are still free the buyer gets a fresh window and the card form
346
+ * they were already looking at. If they are genuinely gone, another 409 says
347
+ * so — and that is the point at which "change your selection" is the honest
348
+ * next step, not before.
349
+ */
350
+ const retryHold = useCallback(async () => {
351
+ if (!created?.orderId && !checkoutId) return;
352
+ try {
353
+ await flow.start();
354
+ } catch {
355
+ // The refusal is already on `flow.error`; a rethrow here would only turn a
356
+ // handled state into an unhandled rejection.
357
+ }
358
+ // eslint-disable-next-line react-hooks/exhaustive-deps
359
+ }, [created?.orderId, checkoutId]);
360
+
292
361
  // A coupon re-issues the payment intent, so its update supersedes the initial
293
362
  // start-payment result. Otherwise use the started payment once both the order
294
363
  // and start-payment have resolved.
@@ -297,12 +366,19 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
297
366
  // `checkoutId` as well as `created`: a BUNDLE has no `created` order, and
298
367
  // gating on that alone left a bundle buyer with no payment element at all
299
368
  // unless a coupon happened to re-mint one.
300
- 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) {
301
375
  return {
302
- paymentSecret: flow.result.paymentSecret,
303
- paymentId: flow.result.paymentId,
304
- chargedAmount: flow.result.amount ?? 0,
305
- 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 ?? "",
306
382
  };
307
383
  }
308
384
  return null;
@@ -442,9 +518,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
442
518
  setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
443
519
  setCouponTaxQuote(data.taxQuote ?? null);
444
520
  setCouponPaymentUpdate(
445
- data.paymentSecret && data.paymentId
521
+ (data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId
446
522
  ? {
447
523
  paymentSecret: data.paymentSecret,
524
+ accessCode: data.accessCode,
525
+ checkoutUrl: data.checkoutUrl,
448
526
  paymentId: data.paymentId,
449
527
  chargedAmount: data.chargedAmount,
450
528
  chargedCurrency: data.chargedCurrency,
@@ -499,9 +577,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
499
577
  setDiscountAmount(0);
500
578
  setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
501
579
  setCouponTaxQuote(data.taxQuote ?? null);
502
- if (data.paymentSecret && data.paymentId) {
580
+ if ((data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId) {
503
581
  setCouponPaymentUpdate({
504
582
  paymentSecret: data.paymentSecret,
583
+ accessCode: data.accessCode,
584
+ checkoutUrl: data.checkoutUrl,
505
585
  paymentId: data.paymentId,
506
586
  chargedAmount: data.chargedAmount,
507
587
  chargedCurrency: data.chargedCurrency,
@@ -544,6 +624,36 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
544
624
 
545
625
  const provider = flow.provider;
546
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
+
547
657
  return {
548
658
  // ── Cart + totals ────────────────────────────────────────────────────────
549
659
  cartItems,
@@ -589,8 +699,34 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
589
699
  ticketItems,
590
700
  isCreatingOrder: createOrder.isPending || flow.isStarting,
591
701
  startError,
702
+ // ── Inventory hold ─────────────────────────────────────────────────────────
703
+ /**
704
+ * The live reservation: `isHeld` + `label` while it runs, `hasLapsed` once
705
+ * the clock hits zero. Every field is null/false when no hold was taken, so
706
+ * a surface guarding on `isHeld || hasLapsed` is unchanged for the profiles
707
+ * that have holds switched off.
708
+ */
709
+ hold,
710
+ /**
711
+ * The server said the reservation expired (409 `INVENTORY_HOLD_EXPIRED`) —
712
+ * its own words, already localised. Empty for every other failure, so this
713
+ * can never be mistaken for sold-out.
714
+ */
715
+ holdExpiredError,
716
+ isHoldExpired,
717
+ /** Re-take the reservation on the same order/bundle. See `retryHold`. */
718
+ retryHold,
719
+ isRetryingHold: flow.isStarting,
592
720
  provider,
593
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,
594
730
  paymentSecret: effectivePayment?.paymentSecret,
595
731
  paymentId: effectivePayment?.paymentId,
596
732
  chargedAmount: effectivePayment?.chargedAmount ?? 0,
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { formatHoldRemaining, holdMsRemaining } from "./inventoryHold";
3
+
4
+ export interface InventoryHoldState {
5
+ /**
6
+ * True only while a real reservation is running. False both when there is no
7
+ * hold at all and once one has run out — the two are told apart by
8
+ * `hasLapsed`, and no surface should draw a timer off this alone.
9
+ */
10
+ isHeld: boolean;
11
+ /** True when a hold existed and its clock reached zero on this device. */
12
+ hasLapsed: boolean;
13
+ /** The instant the API reported, unchanged; `null` when no hold was taken. */
14
+ expiresAt: string | null;
15
+ /** Milliseconds left, or `null` when there is no hold. */
16
+ msRemaining: number | null;
17
+ /** Whole seconds left, or `null` when there is no hold. */
18
+ secondsRemaining: number | null;
19
+ /** `m:ss` (or `h:mm:ss`) for display; `null` when there is nothing to count. */
20
+ label: string | null;
21
+ /** Under two minutes — the point at which a surface should stop being subtle. */
22
+ isUrgent: boolean;
23
+ }
24
+
25
+ export interface UseInventoryHoldOptions {
26
+ /**
27
+ * Fired once, when a running clock reaches zero. For telling a flow to stop
28
+ * describing the reservation as live; NOT for tearing the payment form down.
29
+ */
30
+ onLapse?: () => void;
31
+ /** Tick interval, ms. Only for tests — a second is what a countdown means. */
32
+ intervalMs?: number;
33
+ }
34
+
35
+ /**
36
+ * Count down an inventory hold.
37
+ *
38
+ * ## What it does at zero, and why
39
+ *
40
+ * It stops, and it flips `hasLapsed`. A timer that reaches 0:00 and then keeps
41
+ * sitting there is worse than no timer: it has told the buyer something has
42
+ * happened and then refuses to say what. So the honest end state is a *changed
43
+ * statement* — "your reservation has ended" — which the calling surface renders
44
+ * in place of the clock, with the same recovery action a 409 gets.
45
+ *
46
+ * It deliberately does **not** hide or disable the payment form. This clock is
47
+ * the buyer's device clock against an instant the server chose; it can be
48
+ * skewed, and the server may still accept the payment (`start-payment`
49
+ * re-acquires the same units when they are still free). Refusing a charge the
50
+ * server would have taken loses a real sale to defend a display detail. The
51
+ * authority stays where it belongs — the 409, when it comes.
52
+ *
53
+ * ## Degrading to today's behaviour
54
+ *
55
+ * Holds are behind a per-profile switch that most profiles have off, and a free
56
+ * or already-settled order never carries one either. In all of those cases
57
+ * `holdExpiresAt` is `null`/absent, every field here is null/false, and a
58
+ * surface that guards on `isHeld || hasLapsed` renders exactly what it renders
59
+ * today.
60
+ */
61
+ export function useInventoryHold(
62
+ holdExpiresAt: string | null | undefined,
63
+ opts: UseInventoryHoldOptions = {},
64
+ ): InventoryHoldState {
65
+ const { onLapse, intervalMs = 1000 } = opts;
66
+ const [msRemaining, setMsRemaining] = useState<number | null>(() => holdMsRemaining(holdExpiresAt, Date.now()));
67
+ // Keeps the callback out of the effect's dependencies, so an inline arrow
68
+ // from the caller does not restart the interval on every render.
69
+ const onLapseRef = useRef(onLapse);
70
+ onLapseRef.current = onLapse;
71
+
72
+ useEffect(() => {
73
+ const initial = holdMsRemaining(holdExpiresAt, Date.now());
74
+ setMsRemaining(initial);
75
+ // No hold, or one that was already over when we first saw it (a resumed tab,
76
+ // a skewed clock): nothing to tick. The already-over case still reports
77
+ // `hasLapsed` below — it just never animated down to it.
78
+ if (initial === null || initial <= 0) return;
79
+
80
+ const id = setInterval(() => {
81
+ const next = holdMsRemaining(holdExpiresAt, Date.now());
82
+ setMsRemaining(next);
83
+ if (next !== null && next <= 0) {
84
+ clearInterval(id);
85
+ onLapseRef.current?.();
86
+ }
87
+ }, intervalMs);
88
+ return () => clearInterval(id);
89
+ }, [holdExpiresAt, intervalMs]);
90
+
91
+ const hasHold = msRemaining !== null;
92
+ const hasLapsed = hasHold && msRemaining <= 0;
93
+ const isHeld = hasHold && msRemaining > 0;
94
+
95
+ return {
96
+ isHeld,
97
+ hasLapsed,
98
+ expiresAt: holdExpiresAt ?? null,
99
+ msRemaining,
100
+ secondsRemaining: hasHold ? Math.ceil(msRemaining / 1000) : null,
101
+ label: isHeld ? formatHoldRemaining(msRemaining) : null,
102
+ isUrgent: isHeld && msRemaining <= 120_000,
103
+ };
104
+ }
@@ -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,