@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
@@ -0,0 +1,277 @@
1
+ /// <reference path="../types/paystack-inline.d.ts" />
2
+
3
+ /**
4
+ * Paystack inline checkout: the ONE place in Forge that knows the popup exists.
5
+ *
6
+ * The fan pays in a modal on the creator's own site instead of being sent to a
7
+ * Paystack-branded page and back. Every pillar (cart, tickets, courses,
8
+ * coaching, invoices, payment links, donations, offers, memberships) reaches
9
+ * Paystack through this function, so there is exactly one implementation of the
10
+ * lazy load, the fallback and the success navigation to reason about.
11
+ *
12
+ * Four properties are load-bearing:
13
+ *
14
+ * 1. **SSR safety.** Forge sites are TanStack Start, so this module is evaluated
15
+ * on the server. Nothing here touches `window`, `document` or the Paystack
16
+ * bundle at module scope; the browser check happens before any of it.
17
+ * 2. **Lazy load.** `@paystack/inline-js` is reached through a dynamic
18
+ * `import()`, so it never enters the initial bundle and a blocked script
19
+ * cannot break page render: it becomes a rejected promise we can handle.
20
+ * 3. **One runtime fallback, not a version shim.** When the modal cannot open
21
+ * (import rejected, `resumeTransaction` threw, or nothing appeared within
22
+ * `MODAL_OPEN_TIMEOUT_MS`), the fan goes to the hosted page carried on
23
+ * `checkoutUrl`. The sale survives a blocked CDN, a CSP, or an extension.
24
+ * This is about browser conditions, never about which Forge version a site
25
+ * compiled against.
26
+ * 4. **Dismissal is not failure.** Closing the modal without paying resolves
27
+ * `"dismissed"`. Nothing is marked failed and the caller can open it again.
28
+ *
29
+ * Stripe is untouched by any of this: it keeps reading `paymentSecret` as the
30
+ * PaymentIntent client secret, exactly as live sites already do.
31
+ */
32
+
33
+ /** How a checkout attempt ended. */
34
+ export type PaystackCheckoutOutcome =
35
+ /** Paid in the modal. */
36
+ | "succeeded"
37
+ /** The fan closed the modal without paying. Retryable, not a failure. */
38
+ | "dismissed"
39
+ /** Paystack reported a genuine failure after the modal opened. */
40
+ | "failed"
41
+ /** The modal could not be used, so the fan was sent to the hosted page. */
42
+ | "redirected"
43
+ /** Nothing could be done here at all (server render, or no session to open). */
44
+ | "unavailable";
45
+
46
+ /** The bits of a completed Paystack transaction the return leg needs. */
47
+ export type PaystackTransactionResult = {
48
+ reference?: string;
49
+ trxref?: string;
50
+ transaction?: string;
51
+ status?: string;
52
+ };
53
+
54
+ /**
55
+ * A started Paystack charge, exactly as `start-payment` reports it.
56
+ *
57
+ * `accessCode` and `checkoutUrl` arrive in the SAME `/transaction/initialize`
58
+ * response, which is why carrying the fallback costs one field rather than a
59
+ * second round trip.
60
+ */
61
+ export type PaystackCheckoutSession = {
62
+ /** Checkout-session access code. Carries the session; no public key needed. */
63
+ accessCode?: string | null;
64
+ /** Hosted checkout URL. The runtime fallback. */
65
+ checkoutUrl?: string | null;
66
+ /** Where the redirect leg would have landed. Used on success by default. */
67
+ returnUrl?: string | null;
68
+ };
69
+
70
+ export type PaystackCheckoutHandlers = {
71
+ /**
72
+ * Paid. When omitted, the helper navigates to `returnUrl` with Paystack's own
73
+ * `reference`/`trxref` appended, byte for byte what the hosted redirect did,
74
+ * so every finalise page keeps working untouched. Supply this only when the
75
+ * surface finalises in place (a modal that shows its own receipt).
76
+ */
77
+ onSuccess?: (result: PaystackTransactionResult) => void;
78
+ /** The fan closed the modal. Re-enable the pay button; change nothing else. */
79
+ onDismiss?: () => void;
80
+ /** A real failure, or no usable session at all. */
81
+ onError?: (message?: string) => void;
82
+ };
83
+
84
+ /**
85
+ * How long to wait for the modal to show itself before deciding it will not.
86
+ *
87
+ * Long enough that a slow phone on a bad connection is not thrown out of a
88
+ * working modal, short enough that a fan staring at a dead button gives up
89
+ * before we do.
90
+ */
91
+ const MODAL_OPEN_TIMEOUT_MS = 4000;
92
+ const MODAL_POLL_MS = 200;
93
+
94
+ const inBrowser = (): boolean => typeof window !== "undefined" && typeof document !== "undefined";
95
+
96
+ /**
97
+ * Is Paystack's checkout iframe actually on screen right now?
98
+ *
99
+ * This decides whether the 4s watchdog falls back to the hosted page, so a
100
+ * false positive strands a fan on a dead button. The obvious selector,
101
+ * `iframe[src*="paystack"]`, produces one in two ways that both happen in
102
+ * practice:
103
+ *
104
+ * 1. A DISMISSED modal's iframe is hidden, not removed. On a second attempt it
105
+ * still matches, so the watchdog concludes the modal opened when nothing
106
+ * did. Hence the visibility check rather than mere presence.
107
+ * 2. Any iframe whose src merely CONTAINS the string, including third-party
108
+ * frames that echo the current page URL back in their own src, which match
109
+ * on any host with "paystack" in it. Hence matching the parsed HOST rather
110
+ * than a substring of the whole URL.
111
+ */
112
+ const modalIsPresent = (): boolean => {
113
+ const frames = Array.from(document.querySelectorAll<HTMLIFrameElement>("iframe[src]"));
114
+ return frames.some((frame) => {
115
+ let host: string;
116
+ try {
117
+ host = new URL(frame.src, window.location.href).hostname.toLowerCase();
118
+ } catch {
119
+ return false;
120
+ }
121
+ if (host !== "paystack.com" && !host.endsWith(".paystack.com")) return false;
122
+
123
+ // Visibility, not layout size. `getBoundingClientRect` would be the more
124
+ // thorough test, but the checks that matter are the ones Paystack actually
125
+ // uses to put a dismissed modal away, and this keeps the rule assertable.
126
+ if (frame.hidden) return false;
127
+ const style = window.getComputedStyle(frame);
128
+ return style.display !== "none" && style.visibility !== "hidden";
129
+ });
130
+ };
131
+
132
+ const errorMessageOf = (error: unknown): string | undefined => {
133
+ if (typeof error === "string") return error;
134
+ const message = (error as { message?: unknown } | null)?.message;
135
+ return typeof message === "string" ? message : undefined;
136
+ };
137
+
138
+ /**
139
+ * The URL the hosted redirect would have landed on.
140
+ *
141
+ * Paystack appends `reference` and `trxref` to the callback URL, and finalise
142
+ * pages have always been able to read them. Appending the same two params after
143
+ * a modal success is what makes "nothing downstream changes" literally true.
144
+ */
145
+ export function paystackReturnUrl(returnUrl: string, result: PaystackTransactionResult): string {
146
+ const reference = result.reference || result.trxref;
147
+ if (!reference) return returnUrl;
148
+ try {
149
+ const base = inBrowser() ? window.location.href : undefined;
150
+ const url = new URL(returnUrl, base);
151
+ url.searchParams.set("reference", reference);
152
+ url.searchParams.set("trxref", reference);
153
+ return url.toString();
154
+ } catch {
155
+ // A relative URL with no base to resolve against. The caller's own return
156
+ // path is still the right destination; it just carries no reference.
157
+ return returnUrl;
158
+ }
159
+ }
160
+
161
+ /** True when a start-payment result is a Paystack session this helper can act on. */
162
+ export function hasPaystackSession(session: PaystackCheckoutSession | null | undefined): boolean {
163
+ return !!(session && (session.accessCode || session.checkoutUrl));
164
+ }
165
+
166
+ /**
167
+ * Open the Paystack modal for a started charge.
168
+ *
169
+ * Resolves once the attempt has ended one way or another; it never rejects, so
170
+ * a caller can `await` it from a click handler without a try/catch.
171
+ */
172
+ export async function openPaystackCheckout(
173
+ session: PaystackCheckoutSession,
174
+ handlers: PaystackCheckoutHandlers = {},
175
+ ): Promise<PaystackCheckoutOutcome> {
176
+ // Server render: do nothing at all, and say so rather than throwing.
177
+ if (!inBrowser()) return "unavailable";
178
+
179
+ const accessCode = session.accessCode || undefined;
180
+ const checkoutUrl = session.checkoutUrl || undefined;
181
+
182
+ if (!accessCode && !checkoutUrl) {
183
+ handlers.onError?.("This payment could not be started. Please try again.");
184
+ return "unavailable";
185
+ }
186
+
187
+ /** The one runtime fallback: the hosted page the same initialize call returned. */
188
+ const goToHostedPage = (): PaystackCheckoutOutcome => {
189
+ if (!checkoutUrl) {
190
+ handlers.onError?.("The payment window could not be opened. Please try again.");
191
+ return "unavailable";
192
+ }
193
+ window.location.href = checkoutUrl;
194
+ return "redirected";
195
+ };
196
+
197
+ if (!accessCode) return goToHostedPage();
198
+
199
+ let PaystackPop: typeof import("@paystack/inline-js").default;
200
+ try {
201
+ // Lazily imported so a blocked CDN, an offline chunk or a CSP is a rejected
202
+ // promise here instead of a page that will not render.
203
+ PaystackPop = (await import("@paystack/inline-js")).default;
204
+ } catch {
205
+ return goToHostedPage();
206
+ }
207
+
208
+ return new Promise<PaystackCheckoutOutcome>((resolve) => {
209
+ let settled = false;
210
+ let opened = false;
211
+ let poll: ReturnType<typeof setInterval> | undefined;
212
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
213
+
214
+ const stopWatching = () => {
215
+ if (poll) clearInterval(poll);
216
+ if (watchdog) clearTimeout(watchdog);
217
+ poll = undefined;
218
+ watchdog = undefined;
219
+ };
220
+
221
+ const settle = (outcome: PaystackCheckoutOutcome) => {
222
+ if (settled) return false;
223
+ settled = true;
224
+ stopWatching();
225
+ resolve(outcome);
226
+ return true;
227
+ };
228
+
229
+ const succeed = (result: PaystackTransactionResult) => {
230
+ if (!settle("succeeded")) return;
231
+ if (handlers.onSuccess) {
232
+ handlers.onSuccess(result);
233
+ return;
234
+ }
235
+ // No handler: continue down the exact path the redirect used.
236
+ if (session.returnUrl) window.location.href = paystackReturnUrl(session.returnUrl, result);
237
+ };
238
+
239
+ try {
240
+ new PaystackPop().resumeTransaction(accessCode, {
241
+ onSuccess: (transaction) => succeed(transaction ?? {}),
242
+ // Closing the popup leaves the charge exactly where it was. Nothing is
243
+ // failed, nothing is cancelled server-side, and the same access code
244
+ // still opens the same session on the next click.
245
+ onCancel: () => {
246
+ if (settle("dismissed")) handlers.onDismiss?.();
247
+ },
248
+ // The iframe is up, so the watchdog has nothing left to rescue.
249
+ onLoad: () => {
250
+ opened = true;
251
+ stopWatching();
252
+ },
253
+ onError: (error) => {
254
+ if (settle("failed")) handlers.onError?.(errorMessageOf(error));
255
+ },
256
+ });
257
+ } catch {
258
+ // `resumeTransaction` threw synchronously, so the modal never got started.
259
+ settle(goToHostedPage());
260
+ return;
261
+ }
262
+
263
+ poll = setInterval(() => {
264
+ if (!modalIsPresent()) return;
265
+ opened = true;
266
+ stopWatching();
267
+ }, MODAL_POLL_MS);
268
+
269
+ watchdog = setTimeout(() => {
270
+ stopWatching();
271
+ if (settled || opened) return;
272
+ // The modal never appeared. Send the fan to the hosted page rather than
273
+ // leaving them looking at a button that did nothing.
274
+ settle(goToHostedPage());
275
+ }, MODAL_OPEN_TIMEOUT_MS);
276
+ });
277
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Where a buyer's presale code lives between typing it and paying with it.
3
+ *
4
+ * ## Why this is a module-level store and not component state
5
+ *
6
+ * The code has to be in three places at once, in components that do not share a
7
+ * parent: the event page's own `useEvent` (to reveal the hidden tiers), the
8
+ * ticket modal's `useEventCheckout` (its own separate `useEvent`), and the
9
+ * order body (the sell guard re-checks the code on every purchase, so a buyer
10
+ * who unlocks a tier and then checks out without the code is refused AFTER
11
+ * entering their card). Threading it through props would work for one host app
12
+ * and break for every code-site that composes the Forge pieces differently.
13
+ *
14
+ * ## Why sessionStorage and not localStorage
15
+ *
16
+ * A presale code is a right to buy now, not a saved preference. Session scope
17
+ * survives the reload, the Stripe redirect and the back button — everything
18
+ * inside one purchase — and does not silently re-apply a spent code to a
19
+ * different show weeks later.
20
+ *
21
+ * Keyed per event, because two presales are two different codes.
22
+ */
23
+
24
+ const KEY_PREFIX = "tn:presale:";
25
+
26
+ /** eventKey → code, mirroring sessionStorage so reads are synchronous. */
27
+ const memory = new Map<string, string>();
28
+ const listeners = new Set<() => void>();
29
+
30
+ /** Loaded lazily so a server render never touches storage. */
31
+ const hydrated = new Set<string>();
32
+
33
+ const storageKey = (eventKey: string) => `${KEY_PREFIX}${eventKey}`;
34
+
35
+ function hydrate(eventKey: string): void {
36
+ if (hydrated.has(eventKey) || typeof window === "undefined") return;
37
+ hydrated.add(eventKey);
38
+ try {
39
+ const stored = window.sessionStorage.getItem(storageKey(eventKey));
40
+ if (stored) memory.set(eventKey, stored);
41
+ } catch {
42
+ // Private mode / disabled storage. The code still works for this render;
43
+ // it just will not survive a reload.
44
+ }
45
+ }
46
+
47
+ export function readPresaleCode(eventKey: string): string | null {
48
+ if (!eventKey) return null;
49
+ hydrate(eventKey);
50
+ return memory.get(eventKey) ?? null;
51
+ }
52
+
53
+ export function writePresaleCode(eventKey: string, code: string | null): void {
54
+ if (!eventKey) return;
55
+ hydrated.add(eventKey);
56
+ const trimmed = code?.trim();
57
+ if (trimmed) memory.set(eventKey, trimmed);
58
+ else memory.delete(eventKey);
59
+
60
+ if (typeof window !== "undefined") {
61
+ try {
62
+ if (trimmed) window.sessionStorage.setItem(storageKey(eventKey), trimmed);
63
+ else window.sessionStorage.removeItem(storageKey(eventKey));
64
+ } catch {
65
+ // As above — in-memory only.
66
+ }
67
+ }
68
+
69
+ for (const listener of listeners) listener();
70
+ }
71
+
72
+ export function subscribePresaleCode(listener: () => void): () => void {
73
+ listeners.add(listener);
74
+ return () => {
75
+ listeners.delete(listener);
76
+ };
77
+ }
78
+
79
+ /**
80
+ * `?accessCode=PRESALE24` on the URL, so the link an artist mails to their list
81
+ * unlocks the page on arrival instead of asking the recipient to retype what
82
+ * they just clicked.
83
+ *
84
+ * The parameter is left ON the URL deliberately, unlike the attribution ref: a
85
+ * presale link is meant to be forwardable, and stripping it would break the
86
+ * one thing the recipient is most likely to do with it.
87
+ */
88
+ export function readPresaleCodeFromUrl(): string | null {
89
+ if (typeof window === "undefined") return null;
90
+ try {
91
+ const value = new URL(window.location.href).searchParams.get("accessCode");
92
+ return value?.trim() || null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }