@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +6 -1
- package/src/i18n/en.json +6 -1
- package/src/ui/format/membershipPriceLabel.ts +29 -0
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +136 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +18 -4
- package/src/ui/index.ts +5 -1
- package/src/ui/styled/AccountDashboard.tsx +87 -16
- package/src/ui/styled/FilmLibrary.tsx +61 -24
- package/src/ui/styled/LoginForm.tsx +15 -1
- package/src/ui/styled/MembershipTierCallout.tsx +91 -0
- package/src/ui/styled/MembershipTiers.tsx +5 -15
- package/src/ui/styled/SignupForm.tsx +10 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +49 -34
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +126 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
|
@@ -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 {
|
|
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
|