@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.22.0",
3
+ "version": "3.24.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
+ });
@@ -62,25 +62,76 @@ export interface TrackEventOptions {
62
62
  * `const { track } = useTrackEvent(); track("cta_click", { id: "hero" })`.
63
63
  */
64
64
  export function useTrackEvent(opts?: TrackEventOptions) {
65
- const { client, subdomain: ctxSubdomain } = useForge();
65
+ const { client, apiUrl, subdomain: ctxSubdomain } = useForge();
66
66
  const subdomain = opts?.subdomain ?? ctxSubdomain;
67
67
  const storeSession = opts?.storeSession ?? true;
68
68
 
69
- const track = useCallback(
70
- (eventType: string, eventData: Record<string, unknown> = {}) => {
71
- if (typeof window === "undefined" || !client || !subdomain) return;
69
+ const buildPayload = useCallback(
70
+ (eventData: Record<string, unknown>) => {
72
71
  // Cookieless mode omits the sessionId entirely — the backend derives a
73
72
  // rotating per-day visitor hash so nothing touches the device.
74
- const sessionId = storeSession ? getAnalyticsSessionId(subdomain) : undefined;
73
+ const sessionId = storeSession && subdomain ? getAnalyticsSessionId(subdomain) : undefined;
75
74
  const payload: Record<string, unknown> = { ...eventData };
76
75
  if (sessionId) payload.sessionId = sessionId;
76
+ return payload;
77
+ },
78
+ [subdomain, storeSession],
79
+ );
80
+
81
+ const track = useCallback(
82
+ (eventType: string, eventData: Record<string, unknown> = {}) => {
83
+ if (typeof window === "undefined" || !client || !subdomain) return;
77
84
  // Fire-and-forget: analytics must never block the UI or surface errors.
78
85
  client
79
- .post("/public/websites/track-event", { subdomain, eventType, eventData: payload })
86
+ .post("/public/websites/track-event", { subdomain, eventType, eventData: buildPayload(eventData) })
80
87
  .catch(() => {});
81
88
  },
82
- [client, subdomain, storeSession],
89
+ [client, subdomain, buildPayload],
90
+ );
91
+
92
+ /**
93
+ * Same event, but it survives the page going away.
94
+ *
95
+ * `track` above posts through the Axios instance, and Axios in the browser is
96
+ * XHR. XHR has no `keepalive` option at all, so when a click navigates the tab
97
+ * somewhere else the request is cancelled mid-flight and the event is simply
98
+ * lost. That is tolerable for a click that stays on the site and fatal for one
99
+ * that does not: on a music smart link EVERY conversion is an outbound
100
+ * navigation to Spotify, so the ordinary transport would drop a large share of
101
+ * the only events the page exists to record, and take the server-side Meta
102
+ * `Lead` down with them.
103
+ *
104
+ * `fetch` with `keepalive: true` is the fix. Deliberately not
105
+ * `navigator.sendBeacon`, which cannot set `Content-Type: application/json`
106
+ * without wrapping the body in a Blob, and this endpoint is JSON-only.
107
+ *
108
+ * Use it for any event immediately followed by a navigation. Ordinary
109
+ * in-page events should keep using `track`, which goes through the configured
110
+ * client and picks up its interceptors.
111
+ */
112
+ const trackBeacon = useCallback(
113
+ (eventType: string, eventData: Record<string, unknown> = {}) => {
114
+ if (typeof window === "undefined" || !apiUrl || !subdomain) return;
115
+ try {
116
+ void fetch(`${apiUrl.replace(/\/$/, "")}/public/websites/track-event`, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ keepalive: true,
120
+ body: JSON.stringify({
121
+ subdomain,
122
+ eventType,
123
+ eventData: buildPayload(eventData),
124
+ // The referrer header only carries the origin cross-origin, so the
125
+ // server cannot work out which page this happened on by itself.
126
+ eventSourceUrl: window.location.href,
127
+ }),
128
+ }).catch(() => {});
129
+ } catch {
130
+ // Best-effort by definition; never disrupt the navigation.
131
+ }
132
+ },
133
+ [apiUrl, subdomain, buildPayload],
83
134
  );
84
135
 
85
- return { track };
136
+ return { track, trackBeacon };
86
137
  }
@@ -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 = {
@@ -0,0 +1,28 @@
1
+ import type { IMusicLink } from "../../types/models";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useQuery } from "@tanstack/react-query";
4
+
5
+ /**
6
+ * One music smart link by slug.
7
+ *
8
+ * Pass `initialData` from the route loader (`fetchMusicLinkServer`) so the page
9
+ * renders complete on the server with no client spinner. That is not just a
10
+ * polish detail here: these links are opened almost entirely from social apps'
11
+ * in-app browsers on a phone, on the worst connection the fan will use all day,
12
+ * and a page that shows a spinner before its buttons loses the tap.
13
+ *
14
+ * `undefined` while loading, `null` when the link does not exist or is archived.
15
+ */
16
+ export function useMusicLink(slug?: string, options?: { initialData?: IMusicLink | null }) {
17
+ const { client, profileId } = useForge();
18
+
19
+ return useQuery<IMusicLink | null>({
20
+ queryKey: ["music-link", profileId, slug],
21
+ queryFn: async () => {
22
+ const res = await client.get(`/public/music-links`, { params: { profileId, slug } });
23
+ return res.data ?? null;
24
+ },
25
+ enabled: !!profileId && !!client && !!slug,
26
+ ...(options?.initialData !== undefined ? { initialData: options.initialData } : {}),
27
+ });
28
+ }
@@ -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;