@tribe-nest/forge 3.22.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 (69) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/useAuthActions.ts +1 -1
  4. package/src/data/queries/useCheckouts.ts +5 -0
  5. package/src/data/queries/useEventWaitlist.ts +123 -5
  6. package/src/data/queries/useEvents.ts +16 -0
  7. package/src/data/queries/useMyBookings.ts +25 -0
  8. package/src/data/queries/usePaymentFlow.ts +84 -18
  9. package/src/data/queries/useShipping.ts +5 -0
  10. package/src/data/queries/useSubscriptions.ts +50 -4
  11. package/src/index.ts +5 -0
  12. package/src/server/platform.ts +1 -1
  13. package/src/server/platformEvents.generated.ts +33 -33
  14. package/src/server/pwa.ts +2 -2
  15. package/src/types/models.ts +52 -5
  16. package/src/types/paystack-inline.d.ts +45 -0
  17. package/src/ui/format/_tests/attendees.spec.ts +4 -4
  18. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  19. package/src/ui/format/attendees.ts +2 -2
  20. package/src/ui/format/ticketAvailability.ts +226 -0
  21. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  22. package/src/ui/headless/checkout/useCheckout.ts +72 -8
  23. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  24. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  25. package/src/ui/headless/donation/Donation.tsx +10 -0
  26. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  27. package/src/ui/headless/event/useEventCheckout.ts +70 -0
  28. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  29. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  30. package/src/ui/headless/offer/Offer.tsx +19 -1
  31. package/src/ui/headless/offer/OfferContext.tsx +14 -1
  32. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  33. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  34. package/src/ui/index.ts +22 -0
  35. package/src/ui/shell/diagnosticsGating.ts +3 -3
  36. package/src/ui/styled/AccountDashboard.tsx +73 -4
  37. package/src/ui/styled/AudioPlayer.tsx +1 -1
  38. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  39. package/src/ui/styled/Checkout.tsx +25 -32
  40. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  41. package/src/ui/styled/CoachingBooking.tsx +11 -2
  42. package/src/ui/styled/CoachingConfirmation.tsx +3 -3
  43. package/src/ui/styled/CohortPage.tsx +1 -1
  44. package/src/ui/styled/ContactForm.tsx +1 -1
  45. package/src/ui/styled/CourseCheckout.tsx +10 -1
  46. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  47. package/src/ui/styled/DiscountCode.tsx +1 -1
  48. package/src/ui/styled/EmailListForm.tsx +1 -1
  49. package/src/ui/styled/EventConfirmation.tsx +1 -1
  50. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  51. package/src/ui/styled/EventTickets.tsx +135 -21
  52. package/src/ui/styled/EventWaitlist.tsx +23 -3
  53. package/src/ui/styled/HoldNotice.tsx +2 -2
  54. package/src/ui/styled/InvoicePayment.tsx +14 -5
  55. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  56. package/src/ui/styled/OfferButton.tsx +1 -1
  57. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  58. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  59. package/src/ui/styled/PresaleCode.tsx +1 -1
  60. package/src/ui/styled/ReviewForm.tsx +2 -2
  61. package/src/ui/styled/TicketTransfer.tsx +3 -3
  62. package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
  63. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  64. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  65. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  66. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  67. package/src/utils/membershipAccess.ts +3 -3
  68. package/src/utils/paystackCheckout.ts +277 -0
  69. package/src/utils/ticketOrderOutcome.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.22.0",
3
+ "version": "3.23.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,6 +20,7 @@
20
20
  "test:watch": "vitest"
21
21
  },
22
22
  "dependencies": {
23
+ "@paystack/inline-js": "^2.24.0",
23
24
  "@radix-ui/react-dialog": "^1.1.14",
24
25
  "@radix-ui/react-slot": "^1.2.3",
25
26
  "@stripe/react-stripe-js": "^3.8.0",
@@ -6,6 +6,7 @@ import {
6
6
  isLiveWaitlistEntry,
7
7
  isTicketSoldOut,
8
8
  rememberWaitlistEntry,
9
+ readWaitlistClaimFromUrl,
9
10
  ticketSeatsRemaining,
10
11
  } from "../useEventWaitlist";
11
12
  import type { IEvent, ITicket } from "../../../types/models";
@@ -120,3 +121,40 @@ describe("the anonymous joiner's remembered capability ids", () => {
120
121
  expect(getRememberedWaitlistEntries("e1")).toEqual([]);
121
122
  });
122
123
  });
124
+
125
+ /**
126
+ * Events 2.6b — the offer email's claim link.
127
+ *
128
+ * `?waitlistEntry=<uuid>&waitlistClaim=<64 hex>`. The parse is deliberately
129
+ * strict about the token's shape so a junk query string never becomes a request,
130
+ * and a link missing either half is simply not a claim — the visitor gets the
131
+ * ordinary page rather than a broken one.
132
+ */
133
+ describe("readWaitlistClaimFromUrl", () => {
134
+ const token = "a".repeat(64);
135
+
136
+ it("reads a well-formed claim", () => {
137
+ expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${token}`)).toEqual({
138
+ entryId: "entry-1",
139
+ token,
140
+ });
141
+ });
142
+
143
+ it("survives the other query parameters an email link picks up", () => {
144
+ expect(
145
+ readWaitlistClaimFromUrl(`?utm_source=email&waitlistClaim=${token}&waitlistEntry=entry-9&ref=x`),
146
+ ).toEqual({ entryId: "entry-9", token });
147
+ });
148
+
149
+ it("is null when either half is missing", () => {
150
+ expect(readWaitlistClaimFromUrl(`?waitlistClaim=${token}`)).toBeNull();
151
+ expect(readWaitlistClaimFromUrl("?waitlistEntry=entry-1")).toBeNull();
152
+ expect(readWaitlistClaimFromUrl("")).toBeNull();
153
+ });
154
+
155
+ it("rejects a token that is not exactly 64 lowercase hex characters", () => {
156
+ for (const bad of ["nope", "A".repeat(64), "a".repeat(63), "a".repeat(65), "../../etc"]) {
157
+ expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${bad}`)).toBeNull();
158
+ }
159
+ });
160
+ });
@@ -39,7 +39,7 @@ export function useForgotPassword() {
39
39
  if (!contextId) {
40
40
  // Better than posting without it: the endpoint would 400 on schema
41
41
  // validation and the form would show "something went wrong".
42
- throw new Error("useForgotPassword: no profileId pass one or render inside a ForgeProvider with profileId");
42
+ throw new Error("useForgotPassword: no profileId. Pass one or render inside a ForgeProvider with profileId");
43
43
  }
44
44
  const res = await client.post("/public/sessions/forgot-password", {
45
45
  profileId: contextId,
@@ -143,7 +143,12 @@ export type ApplyCheckoutCouponResult = {
143
143
  /** NET = max(0, subtotalCents − discountCents), minor units. */
144
144
  totalCents: number;
145
145
  appliedCoupons: AppliedBundleCoupon[];
146
+ /** STRIPE: the re-issued PaymentIntent client secret. */
146
147
  paymentSecret: string;
148
+ /** PAYSTACK: the re-issued inline-checkout session. */
149
+ accessCode?: string;
150
+ /** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
151
+ checkoutUrl?: string;
147
152
  paymentId: string;
148
153
  chargedAmount: number;
149
154
  chargedCurrency: string;
@@ -17,6 +17,10 @@ import type { IEvent, ITicket } from "../../types/models";
17
17
  * POST /public/events/:eventId/waitlist join → WaitlistEntry (201)
18
18
  * GET /public/events/:eventId/waitlist mine → WaitlistEntry[] (401 if anonymous)
19
19
  * GET /public/events/:eventId/waitlist/:entryId one → WaitlistEntry
20
+ * …optionally `&claimToken=<64 hex>` — the offer email's credential (2.6b),
21
+ * which is the ONLY way an offered fan can read their place on a device
22
+ * that never joined and is not signed in. That is the device the email is
23
+ * opened on.
20
24
  * DELETE /public/events/:eventId/waitlist/:entryId leave → { id, status, leftAt }
21
25
  * ```
22
26
  *
@@ -49,6 +53,22 @@ export type WaitlistOffer = {
49
53
  secondsRemaining: number | null;
50
54
  /** The instant the offer lapses — the single most important field here. */
51
55
  claimExpiresAt: string | null;
56
+ /**
57
+ * Are the offered seats actually HELD for this person until the deadline
58
+ * (events 2.6b)?
59
+ *
60
+ * Distinct from `offerOpen`, and the distinction is the whole change. An offer
61
+ * used to reserve nothing — the fan raced the open page, and the copy said so.
62
+ * A reserved offer takes a real inventory hold for the length of the window,
63
+ * so the tier reads sold out to everybody else. Render "these are yours until
64
+ * Friday" ONLY when this is true; an unreserved offer (one made before the
65
+ * reservation existed, or one whose hold a failed payment released) is still a
66
+ * real offer with a real deadline and must keep the older, weaker promise.
67
+ *
68
+ * Optional so a site pinned to an older API build degrades to "not reserved"
69
+ * rather than claiming a hold that is not there.
70
+ */
71
+ seatsReserved?: boolean;
52
72
  };
53
73
 
54
74
  export type WaitlistEntry = {
@@ -88,14 +108,26 @@ export const isLiveWaitlistEntry = (entry: Pick<WaitlistEntry, "status">): boole
88
108
  // ── Where a join control may be DRAWN ────────────────────────────────────────
89
109
 
90
110
  /**
91
- * Seats a buyer can still take, by the same arithmetic the purchase UI uses.
111
+ * Seats that are not SOLD — the waitlist's question, which is not the purchase
112
+ * UI's question.
92
113
  *
93
114
  * Deliberately ignores `held` (units inside somebody else's live checkout) even
94
115
  * though the server's waitlist rule subtracts it. The difference only ever makes
95
116
  * this stricter: `quantity - sold <= 0` implies `quantity - sold - held <= 0`,
96
117
  * so a tier this calls sold out is always one the server will accept a join for.
97
- * Including `held` would do the opposite — offer a waitlist beside a "+" button
98
- * the buyer can still press, on a tier that unfreezes minutes later.
118
+ *
119
+ * The purchase UI DOES subtract `held` it has to, because that is what the
120
+ * server sells on (`ticketSeatsAvailable` in `ui/format/ticketAvailability`).
121
+ * The two are answering different things and the gap between them is deliberate:
122
+ *
123
+ * - a tier with held-but-unsold seats is un-addable and says "ON HOLD", and
124
+ * offers no waitlist, because the seats are coming back;
125
+ * - a tier with nothing unsold says "SOLD OUT" and offers the queue.
126
+ *
127
+ * Collapsing them would put a "join the waitlist" beside a tier that unfreezes
128
+ * five minutes later, and send a fan an email about a seat they could have had
129
+ * by waiting. Identical to `waitlistAvailableSeats` on the server only in shape,
130
+ * not in intent — that one gates OFFERS, this one gates where a join is drawn.
99
131
  */
100
132
  export const ticketSeatsRemaining = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
101
133
  Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
@@ -334,6 +366,16 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
334
366
  const { client, profileId } = useForge();
335
367
  const mine = useMyEventWaitlist(eventId, accountId);
336
368
  const remembered = useRememberedWaitlistEntries(eventId);
369
+ /**
370
+ * 2.6b — a THIRD source: the offer the visitor arrived holding.
371
+ *
372
+ * The other two both assume continuity with the browser that joined — a
373
+ * session, or a remembered id in this localStorage. An offer email is read on
374
+ * a phone, and neither holds there, so the fan landed on an ordinary sold-out
375
+ * page with a "join the waitlist" form beside the seats already reserved for
376
+ * them. The token in the link is what makes that place readable.
377
+ */
378
+ const claim = useEventWaitlistClaim(eventId);
337
379
 
338
380
  const entryQueries = useQueries({
339
381
  queries: remembered.map((item) => ({
@@ -359,15 +401,21 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
359
401
  const entry = query.data;
360
402
  if (entry && !byId.has(entry.id)) byId.set(entry.id, entry);
361
403
  }
404
+ // Last, and unconditionally: an entry the link addresses is the one the
405
+ // visitor is here about. `set` rather than a has-check so the token-scoped
406
+ // read wins if the same row arrived by another route.
407
+ if (claim.entry) byId.set(claim.entry.id, claim.entry);
362
408
  return [...byId.values()];
363
409
  // eslint-disable-next-line react-hooks/exhaustive-deps
364
- }, [mine.data, entryStamp]);
410
+ }, [mine.data, entryStamp, claim.entry]);
365
411
 
366
412
  return {
367
413
  entries,
368
414
  /** Only the LIVE ones — what "am I on the waitlist?" means to a buyer. */
369
415
  liveEntries: useMemo(() => entries.filter(isLiveWaitlistEntry), [entries]),
370
- isLoading: mine.isLoading || entryQueries.some((query) => query.isLoading),
416
+ /** The claim this visitor arrived with, for the surface that spends it. */
417
+ claim,
418
+ isLoading: mine.isLoading || claim.isLoading || entryQueries.some((query) => query.isLoading),
371
419
  };
372
420
  }
373
421
 
@@ -427,3 +475,73 @@ export function useMyWaitlistPlaces(accountId?: string) {
427
475
  isLoading: eventsLoading || listQueries.some((query) => query.isLoading),
428
476
  };
429
477
  }
478
+
479
+ // ── The offer email's claim link (2.6b) ──────────────────────────────────────
480
+
481
+ /**
482
+ * The claim credential an offer email hands out.
483
+ *
484
+ * The link used to be the ordinary public event page, which proved nothing about
485
+ * who was holding it — and once the offer started RESERVING seats it could not
486
+ * even be used, because the reservation makes the tier read sold out to
487
+ * everybody, the offeree included. The email now carries
488
+ * `?waitlistEntry=<id>&waitlistClaim=<64 hex>`: the id addresses the place, the
489
+ * token proves the holder and is what lets the reserved hold be spent.
490
+ */
491
+ export type WaitlistClaimLink = { entryId: string; token: string };
492
+
493
+ /** 64 lowercase hex, matching the server's own shape gate. */
494
+ const isWellFormedClaimToken = (value: string | null): value is string =>
495
+ !!value && /^[0-9a-f]{64}$/.test(value);
496
+
497
+ /**
498
+ * Read the claim out of the CURRENT url.
499
+ *
500
+ * Deliberately not stored anywhere. It is a bearer credential with a deadline,
501
+ * and the durable copy is the email; putting it in localStorage would leave a
502
+ * spendable token on a shared device long after the offer died, for no benefit
503
+ * — the fan re-opens the email.
504
+ */
505
+ export const readWaitlistClaimFromUrl = (search?: string): WaitlistClaimLink | null => {
506
+ const raw = search ?? (typeof window === "undefined" ? "" : window.location.search);
507
+ if (!raw) return null;
508
+ const params = new URLSearchParams(raw);
509
+ const entryId = params.get("waitlistEntry");
510
+ const token = params.get("waitlistClaim");
511
+ if (!entryId || !isWellFormedClaimToken(token)) return null;
512
+ return { entryId, token };
513
+ };
514
+
515
+ /**
516
+ * The offer this visitor arrived holding, if they arrived holding one.
517
+ *
518
+ * `enabled` on the token means an ordinary visitor makes no request at all. A
519
+ * dead or foreign token 404s, which is an ANSWER — the offer is over, or the
520
+ * link belongs to another artist — so it is not retried and the caller simply
521
+ * renders the ordinary page.
522
+ */
523
+ export function useEventWaitlistClaim(eventId?: string, search?: string) {
524
+ const { client, profileId } = useForge();
525
+ const link = useMemo(() => readWaitlistClaimFromUrl(search), [search]);
526
+
527
+ const query = useQuery<WaitlistEntry>({
528
+ queryKey: [...waitlistKey(eventId), "claim", link?.entryId, profileId],
529
+ queryFn: async () => {
530
+ const res = await client.get(`/public/events/${eventId}/waitlist/${link!.entryId}`, {
531
+ params: { profileId, claimToken: link!.token },
532
+ });
533
+ return res.data;
534
+ },
535
+ enabled: !!eventId && !!profileId && !!client && !!link,
536
+ retry: false,
537
+ });
538
+
539
+ return {
540
+ /** The raw token, to be posted with the order that spends the reservation. */
541
+ token: link?.token ?? null,
542
+ entry: query.data ?? null,
543
+ /** True only while the seats are genuinely held and the window is open. */
544
+ isClaimable: !!query.data?.offer?.offerOpen && !!query.data?.offer?.seatsReserved,
545
+ isLoading: query.isLoading,
546
+ };
547
+ }
@@ -114,6 +114,22 @@ export type CreateEventOrderInput = {
114
114
  * their order refused after entering their card details.
115
115
  */
116
116
  accessCode?: string;
117
+ /**
118
+ * The waitlist offer's claim token (events 2.6b) — 64 hex from the offer
119
+ * email's link, NOT a discount and NOT a presale code.
120
+ *
121
+ * It is a capability over inventory that is ALREADY reserved: the offer took
122
+ * a real hold when it was made, so presenting this makes the order spend that
123
+ * hold rather than acquire a new one. Without it an offeree cannot buy at
124
+ * all, because their own reservation is what makes the tier read sold out to
125
+ * them.
126
+ *
127
+ * The server refuses a claim it cannot honour (closed window, released
128
+ * reservation, a cart reaching past the offered tier) rather than quietly
129
+ * falling back to an ordinary purchase — so a refusal here is the truth about
130
+ * the offer, not a generic sold-out.
131
+ */
132
+ waitlistClaimToken?: string;
117
133
  };
118
134
 
119
135
  export type CreateEventOrderResult = {
@@ -108,6 +108,8 @@ export type MyBooking = {
108
108
  coachingProductTimezone: string | null;
109
109
  coachingProductDurationMinutes: number | null;
110
110
  cancellation: BookingCancellation;
111
+ /** Already resolved server-side — see {@link BookingLocation}. */
112
+ location?: BookingLocation | null;
111
113
  /** The server's answer. Never recomputed here. */
112
114
  canReschedule: boolean;
113
115
  rescheduleReason: RescheduleRefusal | null;
@@ -125,6 +127,29 @@ export type MyBooking = {
125
127
  calendarWebcalUrl?: string | null;
126
128
  };
127
129
 
130
+ /**
131
+ * WHERE the session happens.
132
+ *
133
+ * The server has already resolved this — the booking's own override if the
134
+ * coach set one for this session, otherwise the coaching product's default —
135
+ * so there is exactly one value and no rule to re-derive in the browser. Two
136
+ * fields would eventually be rendered in the wrong order by one of the two
137
+ * rendering stacks, and the consequence of that is not cosmetic: it is a client
138
+ * dialling into an empty room.
139
+ *
140
+ * `null` means the coach has not said where the session is. Draw nothing; the
141
+ * confirmation email carries the same gap and inventing a placeholder ("Online")
142
+ * would promise a link that does not exist.
143
+ *
144
+ * Withheld for anything not `confirmed`: a cancelled session has no venue to go
145
+ * to and no call to join.
146
+ *
147
+ * Optional on the type because an older API build does not emit it.
148
+ */
149
+ export type BookingLocation =
150
+ | { type: "in_person"; address: string }
151
+ | { type: "online"; url: string };
152
+
128
153
  export type CancelBookingResult = {
129
154
  bookingId: string;
130
155
  /** True when money actually went back; false for a free or already-refunded session. */
@@ -1,6 +1,12 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import type { PaymentFlowResult, PaymentStartResponse } from "../../types/models";
3
3
  import { PaymentProviderName } from "../../types/models";
4
+ import {
5
+ hasPaystackSession,
6
+ openPaystackCheckout,
7
+ type PaystackCheckoutHandlers,
8
+ type PaystackCheckoutOutcome,
9
+ } from "../../utils/paystackCheckout";
4
10
  import { useForge } from "../../provider/ForgeProvider";
5
11
  import { useMutation } from "@tanstack/react-query";
6
12
 
@@ -15,42 +21,80 @@ export interface UsePaymentFlowOptions {
15
21
  autoStart?: boolean;
16
22
  /** Gate auto-start on extra readiness (default true). */
17
23
  enabled?: boolean;
18
- /** Redirect to the Paystack checkout URL on success (default true). */
24
+ /**
25
+ * Handle the Paystack checkout automatically once the charge starts: open the
26
+ * inline modal, falling back to the hosted page when it cannot open
27
+ * (default true). `false` means "I will drive it myself": call
28
+ * `openPaystackCheckout()` from a button.
29
+ *
30
+ * The name is historical (it used to mean "redirect to the hosted page") and
31
+ * is deliberately kept: renaming it would break compiling sites for no gain.
32
+ */
19
33
  paystackRedirect?: boolean;
34
+ /**
35
+ * Paystack modal callbacks. Omit `onPaystackSuccess` to keep the redirect
36
+ * leg's behaviour, which is to navigate to `returnUrl`, and that is what leaves
37
+ * every finalise page unchanged.
38
+ */
39
+ onPaystackSuccess?: PaystackCheckoutHandlers["onSuccess"];
40
+ /** The fan closed the modal. NOT a failure, so leave them able to retry. */
41
+ onPaystackDismiss?: PaystackCheckoutHandlers["onDismiss"];
42
+ onPaystackError?: PaystackCheckoutHandlers["onError"];
20
43
  }
21
44
 
22
45
  /**
23
46
  * Starts a payment against any `/public/<domain>/.../start-payment` endpoint and
24
47
  * normalizes the result: resolves the provider (the field is named differently
25
- * per domain) and, for Paystack, redirects to the hosted checkout. The Stripe
26
- * UI (Elements + confirm) stays in the app this hook only owns the data.
48
+ * per domain) and, for Paystack, opens the inline checkout modal so the fan
49
+ * pays without leaving the creator's site. The Stripe UI (Elements + confirm)
50
+ * stays in the app; this hook only owns the data.
27
51
  */
28
52
  export function usePaymentFlow(opts: UsePaymentFlowOptions) {
29
53
  const { client, profileId } = useForge();
30
54
  const { path, body, returnUrl, autoStart = true, enabled = true, paystackRedirect = true } = opts;
31
55
  const startedRef = useRef(false);
32
56
 
57
+ // Callbacks are read through a ref so a caller passing inline arrows does not
58
+ // re-trigger the auto-open effect on every render.
59
+ const handlersRef = useRef<PaystackCheckoutHandlers>({});
60
+ handlersRef.current = {
61
+ onSuccess: opts.onPaystackSuccess,
62
+ onDismiss: opts.onPaystackDismiss,
63
+ onError: opts.onPaystackError,
64
+ };
65
+
33
66
  const mutation = useMutation<PaymentFlowResult, unknown, Record<string, unknown> | undefined>({
34
67
  mutationFn: async (override) => {
35
68
  // override wins — click-driven flows pass a just-created orderId/returnUrl.
36
69
  const res = await client.post(path as string, { ...body, profileId, returnUrl, ...(override ?? {}) });
37
70
  const data = res.data as PaymentStartResponse;
38
71
  const provider = (data.paymentProvider ?? data.paymentProviderName) as PaymentProviderName | undefined;
39
-
40
- if (
41
- paystackRedirect &&
42
- provider === PaymentProviderName.Paystack &&
43
- data.paymentSecret &&
44
- typeof window !== "undefined"
45
- ) {
46
- window.location.href = data.paymentSecret;
47
- }
48
-
49
72
  return { ...data, provider };
50
73
  },
51
74
  });
52
75
 
53
76
  const { mutate, mutateAsync } = mutation;
77
+ const result = mutation.data ?? null;
78
+ const isPaystack = result?.provider === PaymentProviderName.Paystack;
79
+
80
+ const session = useMemo(
81
+ () => ({ accessCode: result?.accessCode, checkoutUrl: result?.checkoutUrl, returnUrl }),
82
+ [result?.accessCode, result?.checkoutUrl, returnUrl],
83
+ );
84
+
85
+ /**
86
+ * Open the Paystack modal for the charge that has already been started.
87
+ * Safe to call again after a dismissal: the access code still opens the same
88
+ * session, which is what makes "closed it by accident" recoverable.
89
+ */
90
+ const openPaystack = useCallback(async (): Promise<PaystackCheckoutOutcome> => {
91
+ if (!hasPaystackSession(session)) return "unavailable";
92
+ return openPaystackCheckout(session, {
93
+ onSuccess: handlersRef.current.onSuccess,
94
+ onDismiss: handlersRef.current.onDismiss,
95
+ onError: handlersRef.current.onError,
96
+ });
97
+ }, [session]);
54
98
 
55
99
  useEffect(() => {
56
100
  if (autoStart && enabled && path && returnUrl && profileId && !startedRef.current) {
@@ -60,11 +104,32 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
60
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
105
  }, [autoStart, enabled, path, returnUrl, profileId]);
62
106
 
107
+ // Auto-open, once per started charge. Keyed on the charge rather than a bare
108
+ // boolean so a coupon that re-mints the session opens the NEW one, and a fan
109
+ // who dismissed the modal is not immediately re-interrupted by it.
110
+ const autoOpenedRef = useRef<string | null>(null);
111
+ useEffect(() => {
112
+ if (!paystackRedirect || !isPaystack) return;
113
+ const key = result?.paymentId || result?.accessCode;
114
+ if (!key || autoOpenedRef.current === key) return;
115
+ autoOpenedRef.current = key;
116
+ void openPaystack();
117
+ }, [paystackRedirect, isPaystack, result?.paymentId, result?.accessCode, openPaystack]);
118
+
63
119
  return {
64
- result: mutation.data ?? null,
65
- clientSecret: mutation.data?.paymentSecret,
66
- paymentId: mutation.data?.paymentId,
67
- provider: mutation.data?.provider,
120
+ result,
121
+ /** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
122
+ clientSecret: result?.paymentSecret,
123
+ paymentId: result?.paymentId,
124
+ provider: result?.provider,
125
+ /** PAYSTACK: the inline checkout session. Undefined on Stripe. */
126
+ accessCode: result?.accessCode,
127
+ /** PAYSTACK: the hosted page used only when the modal cannot open. */
128
+ checkoutUrl: result?.checkoutUrl,
129
+ isPaystack,
130
+ /** True once there is a Paystack session a pay button can open. */
131
+ canOpenPaystack: hasPaystackSession(session),
132
+ openPaystackCheckout: openPaystack,
68
133
  isStarting: mutation.isPending,
69
134
  error: mutation.error,
70
135
  /**
@@ -86,6 +151,7 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
86
151
  */
87
152
  reset: () => {
88
153
  startedRef.current = false;
154
+ autoOpenedRef.current = null;
89
155
  mutation.reset();
90
156
  },
91
157
  };
@@ -82,7 +82,12 @@ export type ApplyCouponResult = {
82
82
  discountAmount: number;
83
83
  chargedAmount: number;
84
84
  chargedCurrency: string;
85
+ /** STRIPE: the re-issued PaymentIntent client secret. */
85
86
  paymentSecret?: string;
87
+ /** PAYSTACK: the re-issued inline-checkout session. */
88
+ accessCode?: string;
89
+ /** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
90
+ checkoutUrl?: string;
86
91
  paymentId?: string;
87
92
  /** Re-quoted sales tax for the re-issued payment intent (display only). */
88
93
  taxQuote?: import("../../types/models").PublicTaxQuote;
@@ -16,11 +16,38 @@ export type CreateSubscriptionInput = {
16
16
  attributionRefId?: string;
17
17
  };
18
18
 
19
- /** Create a paid membership subscription (returns the payment client secret). */
19
+ /**
20
+ * The two things subscribing can produce.
21
+ *
22
+ * A member with no live subscription gets a provider session to pay with:
23
+ * `clientSecret` on Stripe, `accessCode` + `checkoutUrl` on Paystack. A member
24
+ * who ALREADY subscribes to this artist is changing tier: the server moves their
25
+ * existing subscription onto the new price with proration, so there is nothing
26
+ * to pay right now: `requiresPayment` is false, `changed` is true and no
27
+ * session is issued. Branch on `requiresPayment`, never on the presence of a
28
+ * secret alone. A Paystack subscription has never had one, and reading its
29
+ * absence as "nothing to pay" hands out the tier for free.
30
+ */
31
+ export type CreateSubscriptionResult = {
32
+ membershipId: string;
33
+ subscriptionId: string;
34
+ /** STRIPE ONLY. */
35
+ clientSecret?: string;
36
+ /** PAYSTACK ONLY: inline checkout session. */
37
+ accessCode?: string;
38
+ /** PAYSTACK ONLY: hosted page, the runtime fallback. */
39
+ checkoutUrl?: string;
40
+ currentPeriodStart?: string;
41
+ currentPeriodEnd?: string;
42
+ requiresPayment?: boolean;
43
+ changed?: boolean;
44
+ };
45
+
46
+ /** Subscribe to a paid tier, or move an existing subscription onto another one. */
20
47
  export function useCreateSubscription() {
21
48
  const { client, profileId } = useForge();
22
49
 
23
- return useMutation<{ subscriptionId: string; clientSecret: string }, unknown, CreateSubscriptionInput>({
50
+ return useMutation<CreateSubscriptionResult, unknown, CreateSubscriptionInput>({
24
51
  mutationFn: async (body) => {
25
52
  const res = await client.post("/public/payments/subscriptions", { ...body, profileId });
26
53
  return res.data;
@@ -40,11 +67,30 @@ export function useCreateFreeSubscription() {
40
67
  });
41
68
  }
42
69
 
43
- /** Cancel the member's active paid subscription. */
70
+ export type CancelMembershipResult = {
71
+ membershipId: string;
72
+ /** The status AFTER cancelling — still `active` while a paid period runs out. */
73
+ status: string;
74
+ /** True when the cancellation was scheduled rather than applied immediately. */
75
+ cancelAtPeriodEnd: boolean;
76
+ /** ISO date benefits actually stop. Null when they already have. */
77
+ accessUntil: string | null;
78
+ };
79
+
80
+ /**
81
+ * Cancel the member's membership.
82
+ *
83
+ * Cancelling a paid, paid-up membership does NOT take effect now: the server
84
+ * schedules it for the end of the period they have already paid for and the
85
+ * member keeps their benefits until `accessUntil`. Free tiers (and any
86
+ * membership with nothing paid up) end immediately. Works for every membership
87
+ * the member can still use — including one whose card is failing, which used to
88
+ * be rejected with an error the UI never showed.
89
+ */
44
90
  export function useCancelMembership() {
45
91
  const { client, profileId } = useForge();
46
92
 
47
- return useMutation<unknown, unknown, { membershipId: string }>({
93
+ return useMutation<CancelMembershipResult, unknown, { membershipId: string }>({
48
94
  mutationFn: async ({ membershipId }) => {
49
95
  const res = await client.post("/public/payments/subscriptions/cancel", {
50
96
  membershipId,
package/src/index.ts CHANGED
@@ -127,6 +127,11 @@ export * from "./utils/landing";
127
127
  export * from "./utils/metaPixel";
128
128
  export * from "./utils/cookieConsent";
129
129
  export * from "./utils/structuredData";
130
+ // Paystack inline checkout: the ONE implementation of the popup, exported so a
131
+ // hand-rolled payment surface (or `apps/client`) drives it through the same
132
+ // lazy load, the same runtime fallback and the same return-leg navigation
133
+ // rather than growing a second copy.
134
+ export * from "./utils/paystackCheckout";
130
135
  // The per-booking checkout credential + where it is kept across the payment
131
136
  // redirect. Exported so a host that drives the booking endpoints itself can
132
137
  // hold the secret the same way the built-in flows do.
@@ -376,7 +376,7 @@ export async function handlePlatformEvent(
376
376
  ),
377
377
  budget,
378
378
  `Handler for "${event.event}" took longer than ${budget}ms. Move the slow part into ` +
379
- `enqueueAppJob() and return the platform's delivery attempt times out before this finishes.`,
379
+ `enqueueAppJob() and return. The platform's delivery attempt times out before this finishes.`,
380
380
  );
381
381
  } catch (err) {
382
382
  // 500 so the platform retries. Writes already made carry keys derived from