@tribe-nest/forge 3.21.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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({
|
|
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,
|