@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
@@ -0,0 +1,266 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ /**
5
+ * The Paystack inline checkout helper: the ONE place Forge knows the popup
6
+ * exists, so it is the one place these rules are enforced.
7
+ *
8
+ * What is asserted here is what a fan actually experiences:
9
+ *
10
+ * - the modal opening at all, and paying continuing down the SAME return leg
11
+ * the hosted redirect used (reference and trxref on the URL, so no finalise
12
+ * page has to change);
13
+ * - closing the popup being a retryable non-event rather than a failed order;
14
+ * - and the three ways the modal can fail to appear (a blocked script, a
15
+ * throwing `resumeTransaction`, and a silent no-show), each ending on the
16
+ * hosted page rather than on a dead button.
17
+ *
18
+ * The last of those is the one worth a test: a fan looking at a button that did
19
+ * nothing is a lost sale that reports no error anywhere.
20
+ */
21
+
22
+ type ResumeOptions = {
23
+ onSuccess?: (t: { reference?: string; trxref?: string }) => void;
24
+ onCancel?: () => void;
25
+ onLoad?: (r: unknown) => void;
26
+ onError?: (e: unknown) => void;
27
+ };
28
+
29
+ /** Set per test to drive the fake popup. */
30
+ let resumeBehaviour: (accessCode: string, options: ResumeOptions) => void = () => {};
31
+ const resumeCalls: { accessCode: string }[] = [];
32
+
33
+ // The blocked-script case gets its own file (`paystackCheckoutBlocked.spec.ts`)
34
+ // because a `vi.mock` factory is evaluated once per module registry, so the
35
+ // import cannot be made to fail and succeed within one file.
36
+ vi.mock("@paystack/inline-js", () => {
37
+ return {
38
+ default: class FakePaystackPop {
39
+ resumeTransaction(accessCode: string, options: ResumeOptions = {}) {
40
+ resumeCalls.push({ accessCode });
41
+ resumeBehaviour(accessCode, options);
42
+ return {};
43
+ }
44
+ cancelTransaction() {}
45
+ },
46
+ };
47
+ });
48
+
49
+ // Imported after the mock is registered (vitest hoists `vi.mock`, so a plain
50
+ // top-level import would be fine; this is only for readability).
51
+ import { openPaystackCheckout, paystackReturnUrl, hasPaystackSession } from "../paystackCheckout";
52
+
53
+ const HOSTED = "https://checkout.paystack.com/abc123";
54
+ const RETURN = "https://artist.test/checkout/finalise?orderId=order-1";
55
+
56
+ /** jsdom refuses real navigation, so `location` is replaced with a plain bag. */
57
+ let location: { href: string };
58
+
59
+ const addPaystackIframe = () => {
60
+ const iframe = document.createElement("iframe");
61
+ iframe.setAttribute("src", "https://checkout.paystack.com/frame");
62
+ document.body.appendChild(iframe);
63
+ };
64
+
65
+ beforeEach(() => {
66
+ vi.useFakeTimers();
67
+ resumeCalls.length = 0;
68
+ resumeBehaviour = () => {};
69
+ document.body.innerHTML = "";
70
+ location = { href: "https://artist.test/checkout" };
71
+ Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
72
+ });
73
+
74
+ afterEach(() => {
75
+ vi.useRealTimers();
76
+ });
77
+
78
+ describe("paystackReturnUrl", () => {
79
+ it("appends the reference the hosted redirect would have carried", () => {
80
+ const url = paystackReturnUrl(RETURN, { reference: "pay-uuid" });
81
+ expect(url).toContain("orderId=order-1");
82
+ expect(url).toContain("reference=pay-uuid");
83
+ expect(url).toContain("trxref=pay-uuid");
84
+ });
85
+
86
+ it("falls back to trxref when only that is reported", () => {
87
+ expect(paystackReturnUrl(RETURN, { trxref: "pay-uuid" })).toContain("reference=pay-uuid");
88
+ });
89
+
90
+ it("returns the URL untouched when no reference came back", () => {
91
+ expect(paystackReturnUrl(RETURN, {})).toBe(RETURN);
92
+ });
93
+ });
94
+
95
+ describe("hasPaystackSession", () => {
96
+ it("is true for an access code, true for a hosted URL alone, false for neither", () => {
97
+ expect(hasPaystackSession({ accessCode: "ac" })).toBe(true);
98
+ expect(hasPaystackSession({ checkoutUrl: HOSTED })).toBe(true);
99
+ expect(hasPaystackSession({})).toBe(false);
100
+ expect(hasPaystackSession(null)).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("openPaystackCheckout", () => {
105
+ it("opens the modal and, on success, continues down the redirect's own return leg", async () => {
106
+ resumeBehaviour = (_code, options) => {
107
+ options.onLoad?.({});
108
+ options.onSuccess?.({ reference: "pay-uuid" });
109
+ };
110
+
111
+ const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
112
+
113
+ expect(outcome).toBe("succeeded");
114
+ expect(resumeCalls).toEqual([{ accessCode: "ac_1" }]);
115
+ // The fan stayed on the creator's site right up until the finalise page.
116
+ expect(location.href).toContain("/checkout/finalise");
117
+ expect(location.href).toContain("reference=pay-uuid");
118
+ });
119
+
120
+ it("hands success to the caller instead of navigating when it finalizes in place", async () => {
121
+ const onSuccess = vi.fn();
122
+ resumeBehaviour = (_code, options) => options.onSuccess?.({ reference: "pay-uuid" });
123
+
124
+ const outcome = await openPaystackCheckout(
125
+ { accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN },
126
+ { onSuccess },
127
+ );
128
+
129
+ expect(outcome).toBe("succeeded");
130
+ expect(onSuccess).toHaveBeenCalledWith({ reference: "pay-uuid" });
131
+ expect(location.href).toBe("https://artist.test/checkout");
132
+ });
133
+
134
+ it("treats a dismissal as retryable: no navigation, no error, and the same code opens again", async () => {
135
+ const onError = vi.fn();
136
+ const onDismiss = vi.fn();
137
+ resumeBehaviour = (_code, options) => {
138
+ options.onLoad?.({});
139
+ options.onCancel?.();
140
+ };
141
+
142
+ const first = await openPaystackCheckout(
143
+ { accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN },
144
+ { onDismiss, onError },
145
+ );
146
+
147
+ expect(first).toBe("dismissed");
148
+ expect(onDismiss).toHaveBeenCalledTimes(1);
149
+ // A closed popup is not a failure: nothing is reported and nothing moves.
150
+ expect(onError).not.toHaveBeenCalled();
151
+ expect(location.href).toBe("https://artist.test/checkout");
152
+
153
+ // ... and the very same session opens again on the next click.
154
+ resumeBehaviour = (_code, options) => options.onSuccess?.({ reference: "pay-uuid" });
155
+ const second = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
156
+ expect(second).toBe("succeeded");
157
+ expect(resumeCalls).toEqual([{ accessCode: "ac_1" }, { accessCode: "ac_1" }]);
158
+ });
159
+
160
+ it("reports a genuine provider failure as failed, without redirecting", async () => {
161
+ const onError = vi.fn();
162
+ resumeBehaviour = (_code, options) => {
163
+ options.onLoad?.({});
164
+ options.onError?.({ message: "Card declined" });
165
+ };
166
+
167
+ const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED }, { onError });
168
+
169
+ expect(outcome).toBe("failed");
170
+ expect(onError).toHaveBeenCalledWith("Card declined");
171
+ expect(location.href).toBe("https://artist.test/checkout");
172
+ });
173
+
174
+ it("falls back to the hosted page when resumeTransaction throws", async () => {
175
+ resumeBehaviour = () => {
176
+ throw new Error("popup blew up");
177
+ };
178
+
179
+ const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
180
+
181
+ expect(outcome).toBe("redirected");
182
+ expect(location.href).toBe(HOSTED);
183
+ });
184
+
185
+ it("falls back to the hosted page when the modal never appears", async () => {
186
+ // No onLoad, no iframe: exactly what a silently-blocked popup looks like.
187
+ resumeBehaviour = () => {};
188
+
189
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
190
+ await vi.advanceTimersByTimeAsync(5000);
191
+
192
+ expect(await pending).toBe("redirected");
193
+ expect(location.href).toBe(HOSTED);
194
+ });
195
+
196
+ it("does NOT redirect when the modal is up but the fan is still deciding", async () => {
197
+ let captured: ResumeOptions = {};
198
+ resumeBehaviour = (_code, options) => {
199
+ captured = options;
200
+ addPaystackIframe();
201
+ };
202
+
203
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
204
+ await vi.advanceTimersByTimeAsync(6000);
205
+ // The watchdog has long since fired; the iframe is what stops it acting.
206
+ expect(location.href).toBe("https://artist.test/checkout");
207
+
208
+ captured.onSuccess?.({ reference: "pay-uuid" });
209
+ expect(await pending).toBe("succeeded");
210
+ expect(location.href).toContain("reference=pay-uuid");
211
+ });
212
+
213
+ /**
214
+ * The two ways the modal-present check used to say yes when the fan was
215
+ * looking at nothing. Both suppress the hosted-page fallback, which is the
216
+ * only thing standing between that fan and a dead button.
217
+ */
218
+ it("still falls back when a DISMISSED modal's iframe is hidden rather than removed", async () => {
219
+ const iframe = document.createElement("iframe");
220
+ iframe.setAttribute("src", "https://checkout.paystack.com/frame");
221
+ iframe.style.display = "none";
222
+ document.body.appendChild(iframe);
223
+
224
+ resumeBehaviour = () => {};
225
+
226
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
227
+ await vi.advanceTimersByTimeAsync(5000);
228
+
229
+ expect(await pending).toBe("redirected");
230
+ expect(location.href).toBe(HOSTED);
231
+ });
232
+
233
+ it("still falls back when an unrelated iframe merely mentions paystack in its src", async () => {
234
+ // A third-party frame echoing the current page URL back in its own src is
235
+ // enough to match `iframe[src*="paystack"]` on any paystack-ish host.
236
+ const iframe = document.createElement("iframe");
237
+ iframe.setAttribute("src", "https://metrics.example.com/p?ref=https://paystack.artist.test/checkout");
238
+ document.body.appendChild(iframe);
239
+
240
+ resumeBehaviour = () => {};
241
+
242
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
243
+ await vi.advanceTimersByTimeAsync(5000);
244
+
245
+ expect(await pending).toBe("redirected");
246
+ expect(location.href).toBe(HOSTED);
247
+ });
248
+
249
+ it("goes straight to the hosted page when there is no access code to resume", async () => {
250
+ const outcome = await openPaystackCheckout({ checkoutUrl: HOSTED, returnUrl: RETURN });
251
+
252
+ expect(outcome).toBe("redirected");
253
+ expect(location.href).toBe(HOSTED);
254
+ expect(resumeCalls).toEqual([]);
255
+ });
256
+
257
+ it("reports unavailable, and never navigates, when there is no session at all", async () => {
258
+ const onError = vi.fn();
259
+
260
+ const outcome = await openPaystackCheckout({}, { onError });
261
+
262
+ expect(outcome).toBe("unavailable");
263
+ expect(onError).toHaveBeenCalled();
264
+ expect(location.href).toBe("https://artist.test/checkout");
265
+ });
266
+ });
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+
4
+ /**
5
+ * The blocked-script case, in its own file because a `vi.mock` factory is
6
+ * evaluated once per module registry: the import cannot both fail and succeed
7
+ * inside one spec file.
8
+ *
9
+ * This is the headline reason the Paystack bundle is loaded lazily rather than
10
+ * imported at the top of the module: a CSP, an ad blocker, an offline chunk or
11
+ * a corporate proxy takes the popup away, and the fan must still be able to pay
12
+ * on the hosted page instead of the whole page failing to render.
13
+ */
14
+
15
+ vi.mock("@paystack/inline-js", () => {
16
+ throw new Error("chunk load failed");
17
+ });
18
+
19
+ import { openPaystackCheckout } from "../paystackCheckout";
20
+
21
+ const HOSTED = "https://checkout.paystack.com/abc123";
22
+
23
+ let location: { href: string };
24
+
25
+ beforeEach(() => {
26
+ location = { href: "https://artist.test/checkout" };
27
+ Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
28
+ });
29
+
30
+ describe("openPaystackCheckout when the inline script cannot load", () => {
31
+ it("sends the fan to the hosted page rather than dead-ending them", async () => {
32
+ const outcome = await openPaystackCheckout({
33
+ accessCode: "ac_1",
34
+ checkoutUrl: HOSTED,
35
+ returnUrl: "https://artist.test/checkout/finalise?orderId=order-1",
36
+ });
37
+
38
+ expect(outcome).toBe("redirected");
39
+ expect(location.href).toBe(HOSTED);
40
+ });
41
+
42
+ it("reports the failure instead of navigating when there is no hosted URL to fall back to", async () => {
43
+ const onError = vi.fn();
44
+
45
+ const outcome = await openPaystackCheckout({ accessCode: "ac_1" }, { onError });
46
+
47
+ expect(outcome).toBe("unavailable");
48
+ expect(onError).toHaveBeenCalled();
49
+ expect(location.href).toBe("https://artist.test/checkout");
50
+ });
51
+ });
@@ -161,8 +161,8 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
161
161
  return {
162
162
  label: "Payment failed",
163
163
  detail: until
164
- ? `We couldn't charge your payment method. You still have full access until ${until} update your card to keep it.`
165
- : "We couldn't charge your payment method. You still have full access update your card to keep it.",
164
+ ? `We couldn't charge your payment method. You still have full access until ${until}. Update your card to keep it.`
165
+ : "We couldn't charge your payment method. You still have full access. Update your card to keep it.",
166
166
  showUpdatePayment: true,
167
167
  tone: "warning",
168
168
  };
@@ -171,7 +171,7 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
171
171
  case MEMBERSHIP_BILLING_STATE.pending:
172
172
  return {
173
173
  label: "Pending",
174
- detail: "Your membership isn't active yet finish checkout to unlock it.",
174
+ detail: "Your membership isn't active yet. Finish checkout to unlock it.",
175
175
  showUpdatePayment: false,
176
176
  tone: "neutral",
177
177
  };
@@ -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
+ }
@@ -118,7 +118,7 @@ export const TICKET_ORDER_REFUNDED_COPY = {
118
118
  eyebrow: "Refunded",
119
119
  heading: "This order was refunded",
120
120
  body:
121
- "Your payment has been sent back to the original payment method how long it takes to appear is up to your bank or card issuer. " +
121
+ "Your payment has been sent back to the original payment method. How long it takes to appear is up to your bank or card issuer. " +
122
122
  "These tickets have been cancelled and will not admit entry.",
123
123
  /** Replaces "Total paid" on the amount line. */
124
124
  totalLabel: "Total refunded",