@tribe-nest/forge 3.35.0 → 3.37.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.
@@ -48,7 +48,12 @@ vi.mock("@paystack/inline-js", () => {
48
48
 
49
49
  // Imported after the mock is registered (vitest hoists `vi.mock`, so a plain
50
50
  // top-level import would be fine; this is only for readability).
51
- import { openPaystackCheckout, paystackReturnUrl, hasPaystackSession } from "../paystackCheckout";
51
+ import {
52
+ openPaystackCheckout,
53
+ paystackReturnUrl,
54
+ hasPaystackSession,
55
+ isPaystackPopupOpen,
56
+ } from "../paystackCheckout";
52
57
 
53
58
  const HOSTED = "https://checkout.paystack.com/abc123";
54
59
  const RETURN = "https://artist.test/checkout/finalise?orderId=order-1";
@@ -264,3 +269,79 @@ describe("openPaystackCheckout", () => {
264
269
  expect(location.href).toBe("https://artist.test/checkout");
265
270
  });
266
271
  });
272
+
273
+ /**
274
+ * The popup says when it is up, so the dialog it was opened from can stand
275
+ * down (`ui/headless/dialog.tsx`).
276
+ *
277
+ * A flag left stuck ON is worse than no flag: every dialog on the site would
278
+ * stay non-modal for the rest of the session. So each terminal outcome is
279
+ * asserted separately rather than trusting one shared teardown.
280
+ */
281
+ describe("openPaystackCheckout popup state", () => {
282
+ it("is closed before anything is opened", () => {
283
+ expect(isPaystackPopupOpen()).toBe(false);
284
+ });
285
+
286
+ it("is open while the fan is deciding, and closed again once they pay", async () => {
287
+ let captured: ResumeOptions = {};
288
+ resumeBehaviour = (_code, options) => {
289
+ captured = options;
290
+ options.onLoad?.({});
291
+ };
292
+
293
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
294
+ await vi.advanceTimersByTimeAsync(0);
295
+
296
+ // The card form is on screen: the dialog behind it must not be holding the
297
+ // page's pointer events hostage.
298
+ expect(isPaystackPopupOpen()).toBe(true);
299
+
300
+ captured.onSuccess?.({ reference: "pay-uuid" });
301
+ await pending;
302
+
303
+ expect(isPaystackPopupOpen()).toBe(false);
304
+ });
305
+
306
+ it("closes on a dismissal, so the fan gets a normal dialog back", async () => {
307
+ resumeBehaviour = (_code, options) => {
308
+ options.onLoad?.({});
309
+ options.onCancel?.();
310
+ };
311
+
312
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
313
+
314
+ expect(isPaystackPopupOpen()).toBe(false);
315
+ });
316
+
317
+ it("closes on a provider failure", async () => {
318
+ resumeBehaviour = (_code, options) => {
319
+ options.onLoad?.({});
320
+ options.onError?.({ message: "Card declined" });
321
+ };
322
+
323
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED }, { onError: vi.fn() });
324
+
325
+ expect(isPaystackPopupOpen()).toBe(false);
326
+ });
327
+
328
+ it("closes when the popup never appears and the fan is sent to the hosted page", async () => {
329
+ resumeBehaviour = () => {};
330
+
331
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
332
+ await vi.advanceTimersByTimeAsync(5000);
333
+ await pending;
334
+
335
+ expect(isPaystackPopupOpen()).toBe(false);
336
+ });
337
+
338
+ it("closes when resumeTransaction throws before the popup exists", async () => {
339
+ resumeBehaviour = () => {
340
+ throw new Error("popup blew up");
341
+ };
342
+
343
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
344
+
345
+ expect(isPaystackPopupOpen()).toBe(false);
346
+ });
347
+ });
@@ -129,6 +129,45 @@ const modalIsPresent = (): boolean => {
129
129
  });
130
130
  };
131
131
 
132
+ /**
133
+ * Is a Paystack popup on screen right now?
134
+ *
135
+ * Paystack appends its overlay to `document.body`, which puts it OUTSIDE any
136
+ * dialog the pay button was rendered in. A modal dialog is built to make
137
+ * exactly that unreachable: Radix sets `pointer-events: none` on the body and
138
+ * traps focus inside its own content, so the popup renders on top, looks
139
+ * usable, and swallows every click. A fan on a code website could not pay for
140
+ * tickets, a course, a coaching slot, a film, a donation or an offer, because
141
+ * all six open Paystack from inside a dialog.
142
+ *
143
+ * So the popup announces itself and the dialog primitives get out of the way
144
+ * (`ui/headless/dialog.tsx`). It is published from HERE rather than from a
145
+ * React module because this file is the one funnel every pillar already goes
146
+ * through, and a second place that knows when the popup is up is a second place
147
+ * to forget to clear it.
148
+ */
149
+ let popupOpen = false;
150
+ const popupListeners = new Set<() => void>();
151
+
152
+ /** For `useSyncExternalStore`, and for anything else that needs the answer now. */
153
+ export function isPaystackPopupOpen(): boolean {
154
+ return popupOpen;
155
+ }
156
+
157
+ /** Subscribe to popup open/close. Returns the unsubscribe. */
158
+ export function subscribeToPaystackPopup(listener: () => void): () => void {
159
+ popupListeners.add(listener);
160
+ return () => {
161
+ popupListeners.delete(listener);
162
+ };
163
+ }
164
+
165
+ const setPopupOpen = (next: boolean): void => {
166
+ if (popupOpen === next) return;
167
+ popupOpen = next;
168
+ for (const listener of popupListeners) listener();
169
+ };
170
+
132
171
  const errorMessageOf = (error: unknown): string | undefined => {
133
172
  if (typeof error === "string") return error;
134
173
  const message = (error as { message?: unknown } | null)?.message;
@@ -222,6 +261,10 @@ export async function openPaystackCheckout(
222
261
  if (settled) return false;
223
262
  settled = true;
224
263
  stopWatching();
264
+ // Every terminal outcome passes through here, which is what makes "the
265
+ // popup is up" impossible to leave stuck on: paid, dismissed, failed and
266
+ // fell-back-to-the-hosted-page all clear it.
267
+ setPopupOpen(false);
225
268
  resolve(outcome);
226
269
  return true;
227
270
  };
@@ -237,6 +280,10 @@ export async function openPaystackCheckout(
237
280
  };
238
281
 
239
282
  try {
283
+ // Announced BEFORE the iframe exists, not on `onLoad`. The dialog around
284
+ // the pay button has to have released the page by the time the popup is
285
+ // painted, and a fan taps the first card field the instant they see it.
286
+ setPopupOpen(true);
240
287
  new PaystackPop().resumeTransaction(accessCode, {
241
288
  onSuccess: (transaction) => succeed(transaction ?? {}),
242
289
  // Closing the popup leaves the charge exactly where it was. Nothing is