@tribe-nest/forge 3.21.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -49,8 +49,65 @@ export type MyTicketPass = {
49
49
  ownerEmail?: string | null;
50
50
  /** Set once the pass has been scanned at the door. */
51
51
  checkedInAt?: string | null;
52
+ /**
53
+ * Does the CALLER hold this pass right now?
54
+ *
55
+ * Not the same question as "is it on my order", and the difference is the one
56
+ * that trips people up. A purchaser who named guests at checkout owns passes
57
+ * carrying their guests' addresses from the moment they are written, and a
58
+ * transfer moves the holder without touching the order. Every pass-scoped
59
+ * endpoint — transfer, wallet, QR — resolves the HOLDER, so a control drawn
60
+ * on a pass the caller does not hold can only ever 404.
61
+ *
62
+ * Optional because it is newer than the endpoint. `myTicketHeldPassIds`
63
+ * treats its absence as "assume held", which is the old behaviour.
64
+ */
65
+ isHolder?: boolean;
66
+ /**
67
+ * The caller's OWN live transfer on this pass, straight from the API.
68
+ *
69
+ * Only ever a transfer THIS reader sent (the server matches `from_email`) and
70
+ * only while it is `pending`, so it never exposes the operator-only chain of
71
+ * previous holders. `null` when nothing is in flight.
72
+ *
73
+ * This is what replaced `localStorage` as the source of truth for "you have a
74
+ * transfer out on this ticket": the id used to exist nowhere but the response
75
+ * to the sender's own send, so the same person on a second device had nothing
76
+ * to withdraw.
77
+ */
78
+ pendingTransfer?: MyTicketPendingTransfer | null;
79
+ };
80
+
81
+ /** The live claim window, computed server-side on every read. */
82
+ export type MyTicketTransferWindow = {
83
+ /** The claim link still works right now. */
84
+ open: boolean;
85
+ /** Whole seconds left when the server answered. `null` when there is no window. */
86
+ secondsRemaining: number | null;
87
+ /** The instant the link dies. */
88
+ expiresAt: string | null;
89
+ };
90
+
91
+ /**
92
+ * A pending transfer as the SENDER may see it — deliberately narrow.
93
+ *
94
+ * An id to address the withdraw endpoint with, the recipient THIS caller typed
95
+ * themselves, and the two timestamps that make "sent 2 hours ago, expires in
96
+ * 46" renderable. No token, no previous holder, no account ids.
97
+ */
98
+ export type MyTicketPendingTransfer = {
99
+ id: string;
100
+ status: string;
101
+ toName: string | null;
102
+ toEmail: string;
103
+ expiresAt: string;
104
+ createdAt: string;
105
+ window: MyTicketTransferWindow;
52
106
  };
53
107
 
108
+ /** How the caller comes to be looking at an order. */
109
+ export type MyTicketRole = "purchaser" | "holder";
110
+
54
111
  export type MyTicketItem = {
55
112
  id: string;
56
113
  quantity: number;
@@ -78,6 +135,27 @@ export type MyTicket = {
78
135
  eventStatus: string | null;
79
136
  items: MyTicketItem[];
80
137
  cancellation: TicketCancellation;
138
+ /**
139
+ * `purchaser` — they paid for this order. `holder` — a pass out of it was
140
+ * TRANSFERRED to them and they bought nothing.
141
+ *
142
+ * A holder's row is scrubbed server-side: `totalAmount` and every line price
143
+ * are null, lines they hold no pass on are gone, and `cancellation.canCancel`
144
+ * is false (cancelling refunds the sender's money and voids every seat, which
145
+ * is not a gift recipient's call). Render the money block only for a
146
+ * purchaser — `Number(null)` is `0`, and a confident "$0.00" on somebody
147
+ * else's purchase is worse than saying nothing.
148
+ *
149
+ * Optional: an older API build emits no role, and treating that as
150
+ * `purchaser` reproduces exactly the previous behaviour.
151
+ */
152
+ role?: MyTicketRole;
153
+ /**
154
+ * The event's `.ics`, both forms — what lets the portal offer Apple Calendar
155
+ * rather than only the three template providers. Null for a cancelled show.
156
+ */
157
+ calendarUrl?: string | null;
158
+ calendarWebcalUrl?: string | null;
81
159
  /**
82
160
  * Not emitted by the API, which nests passes under each ITEM. Tolerated by
83
161
  * `myTicketPassIds` so an order-level shape would not need a frontend change.
@@ -111,6 +189,40 @@ export function myTicketPassIds(ticket: Pick<MyTicket, "passes" | "items">): str
111
189
  return [...seen];
112
190
  }
113
191
 
192
+ /** Every pass on an order, flattened — the objects, not just the ids. */
193
+ export function myTicketPasses(ticket: Pick<MyTicket, "passes" | "items">): MyTicketPass[] {
194
+ const seen = new Map<string, MyTicketPass>();
195
+ const collect = (passes?: MyTicketPass[]) => {
196
+ for (const pass of passes ?? []) {
197
+ if (typeof pass?.id === "string" && /^TN-\d+$/.test(pass.id) && !seen.has(pass.id)) {
198
+ seen.set(pass.id, pass);
199
+ }
200
+ }
201
+ };
202
+
203
+ collect(ticket.passes);
204
+ for (const item of ticket.items ?? []) collect(item.passes);
205
+
206
+ return [...seen.values()];
207
+ }
208
+
209
+ /**
210
+ * The passes the caller can actually ACT on — transfer, wallet, QR.
211
+ *
212
+ * Every pass-scoped endpoint resolves the current HOLDER, so drawing those
213
+ * controls on a pass the caller merely paid for (a named guest's seat, a ticket
214
+ * already claimed by somebody else) produced a 404 with no explanation. The
215
+ * affordance is now drawn from the server's own `isHolder`.
216
+ *
217
+ * A pass with no `isHolder` at all is treated as held: that is an older API
218
+ * build, and the previous behaviour was to offer the control for every pass.
219
+ */
220
+ export function myTicketHeldPassIds(ticket: Pick<MyTicket, "passes" | "items">): string[] {
221
+ return myTicketPasses(ticket)
222
+ .filter((pass) => pass.isHolder !== false)
223
+ .map((pass) => pass.id);
224
+ }
225
+
114
226
  /**
115
227
  * The buyer is resolved from the SESSION server-side — `accountId` here only
116
228
  * gates the query on being signed in and keys the cache. It is never sent, and
@@ -56,6 +56,16 @@ export type CreateOrderResult = {
56
56
  totalAmount: number;
57
57
  subTotal: number;
58
58
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
59
+ /**
60
+ * When the stock reserved for this cart goes back on sale, ISO-8601.
61
+ *
62
+ * `null` when nothing was reserved — an order that already settled (a free
63
+ * one), a cart of digital/service items, or a profile with inventory holds
64
+ * switched off. `start-payment` restarts this clock and reports the newer
65
+ * instant, so treat that one as authoritative once it arrives; this is what
66
+ * bridges the gap while it is in flight.
67
+ */
68
+ holdExpiresAt?: string | null;
59
69
  };
60
70
 
61
71
  /** Create a cart/product order (the step before start-payment). */
@@ -37,21 +37,27 @@ import { useForge } from "../../provider/ForgeProvider";
37
37
  * cancelled transfer are all its answers to give, each with a distinct
38
38
  * message.
39
39
  *
40
- * ## Why there is a local store for pending sends
40
+ * ## Where a pending transfer comes from the SERVER, now
41
41
  *
42
42
  * `GET /public/events/passes/:passId/transfers` is gated on the operator
43
- * permission `events.read` the chain names every previous holder, which the
44
- * current one has no business reading and nothing on `/public/events/my-tickets`
45
- * carries transfer state. So a BUYER has no endpoint that lists their own
46
- * pending transfer back to them: the only place a transfer id ever appears is
47
- * the response to their own send.
43
+ * permission `events.read` and stays that way: the chain names every previous
44
+ * holder, which the current one has no business reading. For a while that left
45
+ * a buyer with no endpoint listing their own pending transfer back to them —
46
+ * the id existed nowhere but the response to their own send so this file
47
+ * remembered it in `localStorage`, exactly as `useEventWaitlist` does for an
48
+ * anonymous joiner's entry id.
48
49
  *
49
- * That id is remembered in `localStorage`, exactly as `useEventWaitlist` does
50
- * for an anonymous joiner's entry id and for the same reason — it is the only
51
- * way the browser that performed an action can show it again. It is a
52
- * convenience, not a source of truth: the durable copy is the recipient's email,
53
- * and every cancel is still decided by the server. A buyer on another device
54
- * sees no pending transfer, which is a gap in the API rather than in this file.
50
+ * `/public/events/my-tickets` now carries `pendingTransfer` on each pass:
51
+ * `status = 'pending'` AND a `from_email` match on the reader, so the only row
52
+ * that can surface is one the reader sent. **That is the source of truth.** A
53
+ * sender who signed in on a second device can see and withdraw a transfer they
54
+ * started on the first, which is the case `localStorage` could never serve.
55
+ *
56
+ * The local store is KEPT, demoted to what it always really was: a same-render
57
+ * bridge. `my-tickets` is a paginated query that has to round-trip before the
58
+ * new row appears, and a just-sent transfer vanishing for a second reads as a
59
+ * failed send. `mergePendingTransfers` below prefers the server's copy whenever
60
+ * it has one, so the two can never both draw a row.
55
61
  */
56
62
 
57
63
  /** The lifecycle, restated (Forge cannot import from the API). */
@@ -202,13 +208,67 @@ export const forgetPassTransfer = (transferId: string) => {
202
208
  * The remembered pending sends, as a REACTIVE read.
203
209
  *
204
210
  * There is no query to invalidate, so a plain read would leave a just-sent
205
- * transfer invisible until a reload.
211
+ * transfer invisible until `my-tickets` refetches.
206
212
  */
207
213
  export function usePendingPassTransfers(passId?: string): RememberedPassTransfer[] {
208
214
  const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
209
215
  return useMemo(() => (passId ? all.filter((entry) => entry.passId === passId) : all), [all, passId]);
210
216
  }
211
217
 
218
+ /** One pending transfer, however the caller came by it. */
219
+ export type PendingTransferView = {
220
+ /** The id the withdraw endpoint takes. */
221
+ transferId: string;
222
+ toEmail: string;
223
+ /** The instant the claim link dies. `null` when unknown (a local-only row). */
224
+ expiresAt: string | null;
225
+ /** False once the window has lapsed — server-computed, never re-derived. */
226
+ open: boolean;
227
+ /** True when this came off `my-tickets` rather than out of `localStorage`. */
228
+ fromServer: boolean;
229
+ };
230
+
231
+ /**
232
+ * The pending transfer to DRAW for one pass — the server's if it has one, the
233
+ * local bridge otherwise.
234
+ *
235
+ * De-duplication matters more than it looks: one live transfer per pass is a
236
+ * DATABASE guarantee (a partial unique index), so two rows on screen would be a
237
+ * lie about the ticket's state and would offer two "withdraw" buttons for one
238
+ * thing. The server wins whenever it answers, because it also knows whether the
239
+ * window is still open.
240
+ *
241
+ * `null` means nothing is in flight — a pass with no outstanding transfer, on
242
+ * any device.
243
+ */
244
+ export function mergePendingTransfer(
245
+ serverTransfer: { id: string; toEmail: string; expiresAt: string; window?: { open: boolean } } | null | undefined,
246
+ remembered: RememberedPassTransfer[],
247
+ ): PendingTransferView | null {
248
+ if (serverTransfer) {
249
+ return {
250
+ transferId: serverTransfer.id,
251
+ toEmail: serverTransfer.toEmail,
252
+ expiresAt: serverTransfer.expiresAt ?? null,
253
+ // A lapsed-but-unswept transfer is still `pending` and still withdrawable;
254
+ // the server says `open: false` and the UI says so too rather than hiding
255
+ // a live row the sender cannot otherwise address.
256
+ open: serverTransfer.window?.open ?? true,
257
+ fromServer: true,
258
+ };
259
+ }
260
+
261
+ const local = remembered[0];
262
+ if (!local) return null;
263
+ return {
264
+ transferId: local.transferId,
265
+ toEmail: local.toEmail,
266
+ expiresAt: local.expiresAt,
267
+ open: true,
268
+ fromServer: false,
269
+ };
270
+ }
271
+
212
272
  // ── Hooks ───────────────────────────────────────────────────────────────────
213
273
 
214
274
  /**
@@ -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.
@@ -47,6 +47,14 @@ import type {
47
47
  CollectionSearchResult,
48
48
  } from "../types/models";
49
49
  import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
50
+ // SEO context for structured data. Pure functions over plain data, so this is
51
+ // safe in the React-free server entry.
52
+ import {
53
+ seoContextFromSiteConfig,
54
+ type ReviewSchemaReview,
55
+ type SiteSeoContext,
56
+ } from "../utils/structuredData";
57
+ export type { SiteSeoContext };
50
58
  import type { SiteConfig } from "../data/queries/useWebsite";
51
59
 
52
60
  /**
@@ -121,6 +129,50 @@ export function fetchSiteConfig(opts: { apiUrl: string; profileId?: string }): P
121
129
  return getJson<SiteConfig>(opts.apiUrl, "/public/websites/site-config", { profileId: opts.profileId });
122
130
  }
123
131
 
132
+ /**
133
+ * A sample of an entity's published reviews, for the `review` array in its
134
+ * JSON-LD. Sorted by "helpful" so the sample Google reads is the useful one.
135
+ *
136
+ * Purely an enrichment: the star snippet comes from `aggregateRating`, which
137
+ * every detail payload already carries. Only call this when the entity HAS
138
+ * reviews — the sample is worth one round trip for a reviewed thing and worth
139
+ * none for an unreviewed one.
140
+ */
141
+ export async function fetchEntityReviewsServer(opts: {
142
+ apiUrl: string;
143
+ profileId?: string;
144
+ entityType: "product" | "course" | "coaching_product";
145
+ entityId: string;
146
+ limit?: number;
147
+ }): Promise<ReviewSchemaReview[]> {
148
+ if (!opts.profileId) return [];
149
+ const res = await getJson<{ data: ReviewSchemaReview[] }>(opts.apiUrl, "/public/reviews", {
150
+ profileId: opts.profileId,
151
+ entityType: opts.entityType,
152
+ entityId: opts.entityId,
153
+ page: "1",
154
+ limit: String(opts.limit ?? 5),
155
+ sort: "helpful",
156
+ });
157
+ return res?.data ?? [];
158
+ }
159
+
160
+ /**
161
+ * The tenant facts the JSON-LD builders need (settlement currency + creator
162
+ * name), for a detail route's OWN loader.
163
+ *
164
+ * Fetched per route rather than read off the root match on purpose: during SSR
165
+ * a child `head()` sees the root match with `loaderData: null`, so the root's
166
+ * site config is invisible there. Pair it with the entity fetch in one
167
+ * `Promise.all` and the extra round trip costs nothing wall-clock.
168
+ */
169
+ export async function fetchSeoContextServer(opts: {
170
+ apiUrl: string;
171
+ profileId?: string;
172
+ }): Promise<SiteSeoContext> {
173
+ return seoContextFromSiteConfig(await fetchSiteConfig(opts));
174
+ }
175
+
124
176
  /** The API's unauthenticated liveness route — no tenant, no params, no body. */
125
177
  const PROBE_PATH = "/healthcheck";
126
178
 
@@ -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