@tribe-nest/forge 3.35.0 → 3.36.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 +2 -0
- package/src/i18n/en.json +2 -0
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/index.ts +4 -1
- package/src/ui/styled/AccountDashboard.tsx +73 -1
- package/src/ui/styled/FilmLibrary.tsx +61 -24
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -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
package/package.json
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The way back from a Paystack payment.
|
|
9
|
+
*
|
|
10
|
+
* Four checkouts (event tickets, courses, coaching, offers) build their finalise
|
|
11
|
+
* URL from an id that does not exist until the resource is created, so they hand
|
|
12
|
+
* it to `start({ …, returnUrl })` and never as a hook option. The Paystack
|
|
13
|
+
* session was assembled from the OPTION alone, so for all four it carried no
|
|
14
|
+
* return URL: the buyer paid, the popup closed, and the helper had nowhere to
|
|
15
|
+
* send them. They were left sitting on the checkout they had just paid on, with
|
|
16
|
+
* no receipt, no confirmation and no error — while the charge itself succeeded
|
|
17
|
+
* every time.
|
|
18
|
+
*
|
|
19
|
+
* Stripe never had this: it hands its own `return_url` to `confirmPayment`.
|
|
20
|
+
*
|
|
21
|
+
* The real helper is in the path here, with only `@paystack/inline-js` faked, so
|
|
22
|
+
* this asserts the navigation a fan actually gets rather than what was passed
|
|
23
|
+
* between two of our own functions.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type ResumeOptions = {
|
|
27
|
+
onSuccess?: (t: { reference?: string }) => void;
|
|
28
|
+
onCancel?: () => void;
|
|
29
|
+
onLoad?: (r: unknown) => void;
|
|
30
|
+
onError?: (e: unknown) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let captured: ResumeOptions = {};
|
|
34
|
+
|
|
35
|
+
vi.mock("@paystack/inline-js", () => ({
|
|
36
|
+
default: class FakePaystackPop {
|
|
37
|
+
resumeTransaction(_accessCode: string, options: ResumeOptions = {}) {
|
|
38
|
+
captured = options;
|
|
39
|
+
options.onLoad?.({});
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
cancelTransaction() {}
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const post = vi.fn();
|
|
47
|
+
|
|
48
|
+
vi.mock("../../../provider/ForgeProvider", () => ({
|
|
49
|
+
useForge: () => ({ client: { post }, profileId: "profile-1" }),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
import { usePaymentFlow } from "../usePaymentFlow";
|
|
53
|
+
|
|
54
|
+
const FINALISE = "https://artist.test/i/events/summer-show/finalise?orderId=order-1";
|
|
55
|
+
|
|
56
|
+
const wrapper = ({ children }: { children: ReactNode }) => {
|
|
57
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
58
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let location: { href: string };
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
captured = {};
|
|
65
|
+
post.mockReset();
|
|
66
|
+
post.mockResolvedValue({
|
|
67
|
+
data: { paymentProvider: "paystack", accessCode: "ac_1", checkoutUrl: "https://checkout.paystack.com/ac_1" },
|
|
68
|
+
});
|
|
69
|
+
location = { href: "https://artist.test/i/events/summer-show" };
|
|
70
|
+
Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A ticket checkout: nothing is known until the order exists, so nothing is static. */
|
|
74
|
+
const useClickDrivenFlow = () => usePaymentFlow({ path: "/public/events/event-1/start-payment", autoStart: false });
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pay in the popup the flow opened for itself.
|
|
78
|
+
*
|
|
79
|
+
* `usePaymentFlow` auto-opens once a charge lands, which is what the ticket
|
|
80
|
+
* checkout relies on ("Paystack redirected" is its whole payment step), so the
|
|
81
|
+
* spec waits for that rather than racing it with a hand-driven open.
|
|
82
|
+
*/
|
|
83
|
+
async function payInPopup(reference = "pay-uuid"): Promise<void> {
|
|
84
|
+
await waitFor(() => expect(captured.onSuccess).toBeTypeOf("function"));
|
|
85
|
+
await act(async () => {
|
|
86
|
+
captured.onSuccess?.({ reference });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("usePaymentFlow: returning from a Paystack payment", () => {
|
|
91
|
+
it("REGRESSION: sends the buyer to the finalise URL the charge was started with", async () => {
|
|
92
|
+
const { result } = renderHook(useClickDrivenFlow, { wrapper });
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.start({ orderId: "order-1", returnUrl: FINALISE });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The server was told where to come back to...
|
|
99
|
+
expect(post).toHaveBeenCalledWith(
|
|
100
|
+
"/public/events/event-1/start-payment",
|
|
101
|
+
expect.objectContaining({ orderId: "order-1", returnUrl: FINALISE }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await payInPopup();
|
|
105
|
+
|
|
106
|
+
// ...and so is the buyer. Before this fix `location.href` never moved.
|
|
107
|
+
expect(location.href).toContain("/finalise");
|
|
108
|
+
expect(location.href).toContain("orderId=order-1");
|
|
109
|
+
// The two params every finalise page already reads off a hosted redirect.
|
|
110
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
111
|
+
expect(location.href).toContain("trxref=pay-uuid");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("still honours a static returnUrl for the flows that have one up front", async () => {
|
|
115
|
+
// The cart, film rentals and payment links pass it as an option, and that
|
|
116
|
+
// path must not regress while the click-driven one is fixed.
|
|
117
|
+
const { result } = renderHook(
|
|
118
|
+
() => usePaymentFlow({ path: "/public/payment-links/link-1/start-payment", returnUrl: FINALISE, autoStart: false }),
|
|
119
|
+
{ wrapper },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
await result.current.start();
|
|
124
|
+
});
|
|
125
|
+
await payInPopup();
|
|
126
|
+
|
|
127
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves navigation alone when the surface finalises in place", async () => {
|
|
131
|
+
// Donations show their own thank-you step, so they pass `onPaystackSuccess`
|
|
132
|
+
// and must NOT be navigated away from it.
|
|
133
|
+
const onPaystackSuccess = vi.fn();
|
|
134
|
+
const { result } = renderHook(
|
|
135
|
+
() =>
|
|
136
|
+
usePaymentFlow({ path: "/public/donations/d-1/start-payment", autoStart: false, onPaystackSuccess }),
|
|
137
|
+
{ wrapper },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
await result.current.start({ returnUrl: FINALISE });
|
|
142
|
+
});
|
|
143
|
+
await payInPopup();
|
|
144
|
+
|
|
145
|
+
expect(onPaystackSuccess).toHaveBeenCalledWith({ reference: "pay-uuid" });
|
|
146
|
+
expect(location.href).toBe("https://artist.test/i/events/summer-show");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("carries the LATEST start's URL when a charge is re-minted", async () => {
|
|
150
|
+
// A discount code re-starts the charge. Sending the buyer to the first
|
|
151
|
+
// attempt's finalise page would land them on a different order.
|
|
152
|
+
const { result } = renderHook(useClickDrivenFlow, { wrapper });
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await result.current.start({ orderId: "order-1", returnUrl: "https://artist.test/finalise?orderId=order-1" });
|
|
156
|
+
});
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await result.current.start({ orderId: "order-2", returnUrl: "https://artist.test/finalise?orderId=order-2" });
|
|
159
|
+
});
|
|
160
|
+
await payInPopup();
|
|
161
|
+
|
|
162
|
+
expect(location.href).toContain("orderId=order-2");
|
|
163
|
+
expect(location.href).not.toContain("orderId=order-1");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -63,10 +63,32 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
63
63
|
onError: opts.onPaystackError,
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The return URL the charge was actually STARTED with.
|
|
68
|
+
*
|
|
69
|
+
* Click-driven flows (event tickets, courses, coaching, offers) build their
|
|
70
|
+
* finalise URL from an id that does not exist until the resource is created,
|
|
71
|
+
* so they pass it in the `start()` override and never as a hook option. The
|
|
72
|
+
* Paystack session was read from the OPTION alone, so for all four it was
|
|
73
|
+
* `undefined`: the fan paid, the popup closed, `openPaystackCheckout` had
|
|
74
|
+
* nowhere to send them, and they sat on the checkout they had just paid on
|
|
75
|
+
* with no receipt and no confirmation. The charge was fine every time; only
|
|
76
|
+
* the way back was missing. Stripe was unaffected because it hands its own
|
|
77
|
+
* `return_url` to `confirmPayment`.
|
|
78
|
+
*
|
|
79
|
+
* A ref rather than state: it is read when the popup is opened, never
|
|
80
|
+
* rendered, and a re-render on every start would re-run the auto-open effect.
|
|
81
|
+
*/
|
|
82
|
+
const startedReturnUrlRef = useRef<string | undefined>(undefined);
|
|
83
|
+
|
|
66
84
|
const mutation = useMutation<PaymentFlowResult, unknown, Record<string, unknown> | undefined>({
|
|
67
85
|
mutationFn: async (override) => {
|
|
68
86
|
// override wins — click-driven flows pass a just-created orderId/returnUrl.
|
|
69
|
-
const
|
|
87
|
+
const payload = { ...body, profileId, returnUrl, ...(override ?? {}) };
|
|
88
|
+
// Whatever the server was told, byte for byte. Resolving it any other way
|
|
89
|
+
// is how these two drift apart again.
|
|
90
|
+
startedReturnUrlRef.current = (payload.returnUrl as string | undefined) || undefined;
|
|
91
|
+
const res = await client.post(path as string, payload);
|
|
70
92
|
const data = res.data as PaymentStartResponse;
|
|
71
93
|
const provider = (data.paymentProvider ?? data.paymentProviderName) as PaymentProviderName | undefined;
|
|
72
94
|
return { ...data, provider };
|
|
@@ -88,8 +110,11 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
88
110
|
* session, which is what makes "closed it by accident" recoverable.
|
|
89
111
|
*/
|
|
90
112
|
const openPaystack = useCallback(async (): Promise<PaystackCheckoutOutcome> => {
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
// The URL the charge was started with wins over the static option: for a
|
|
114
|
+
// click-driven flow it is the only one there has ever been.
|
|
115
|
+
const active = { ...session, returnUrl: startedReturnUrlRef.current ?? session.returnUrl };
|
|
116
|
+
if (!hasPaystackSession(active)) return "unavailable";
|
|
117
|
+
return openPaystackCheckout(active, {
|
|
93
118
|
onSuccess: handlersRef.current.onSuccess,
|
|
94
119
|
onDismiss: handlersRef.current.onDismiss,
|
|
95
120
|
onError: handlersRef.current.onError,
|
package/src/i18n/de.json
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"forge.account_dashboard.profile_section_title": "Profilangaben",
|
|
63
63
|
"forge.account_dashboard.profile_update_error": "Profil konnte nicht aktualisiert werden.",
|
|
64
64
|
"forge.account_dashboard.profile_update_success": "Profil aktualisiert.",
|
|
65
|
+
"forge.account_dashboard.rentals_title": "Meine Ausleihen",
|
|
65
66
|
"forge.account_dashboard.request_deletion": "Kontolöschung beantragen",
|
|
66
67
|
"forge.account_dashboard.reschedule": "Verschieben",
|
|
67
68
|
"forge.account_dashboard.reschedule_empty": "In den nächsten zwei Wochen sind keine anderen Zeiten frei.",
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
"forge.account_dashboard.tab_membership": "Mitgliedschaft",
|
|
81
82
|
"forge.account_dashboard.tab_notifications": "Mitteilungen",
|
|
82
83
|
"forge.account_dashboard.tab_orders": "Bestellungen",
|
|
84
|
+
"forge.account_dashboard.tab_rentals": "Ausleihen",
|
|
83
85
|
"forge.account_dashboard.tab_saved": "Gespeichert",
|
|
84
86
|
"forge.account_dashboard.tab_tickets": "Tickets",
|
|
85
87
|
"forge.account_dashboard.tab_waitlist": "Warteliste",
|
package/src/i18n/en.json
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"forge.account_dashboard.profile_section_title": "Profile Information",
|
|
63
63
|
"forge.account_dashboard.profile_update_error": "Failed to update profile.",
|
|
64
64
|
"forge.account_dashboard.profile_update_success": "Profile updated.",
|
|
65
|
+
"forge.account_dashboard.rentals_title": "My Rentals",
|
|
65
66
|
"forge.account_dashboard.request_deletion": "Request Account Deletion",
|
|
66
67
|
"forge.account_dashboard.reschedule": "Reschedule",
|
|
67
68
|
"forge.account_dashboard.reschedule_empty": "No other times are open in the next two weeks.",
|
|
@@ -80,6 +81,7 @@
|
|
|
80
81
|
"forge.account_dashboard.tab_membership": "Membership",
|
|
81
82
|
"forge.account_dashboard.tab_notifications": "Notifications",
|
|
82
83
|
"forge.account_dashboard.tab_orders": "Orders",
|
|
84
|
+
"forge.account_dashboard.tab_rentals": "Rentals",
|
|
83
85
|
"forge.account_dashboard.tab_saved": "Saved",
|
|
84
86
|
"forge.account_dashboard.tab_tickets": "Tickets",
|
|
85
87
|
"forge.account_dashboard.tab_waitlist": "Waitlist",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { act, render, fireEvent } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bug this file exists for: a fan on a code website could not pay with
|
|
7
|
+
* Paystack from inside a dialog.
|
|
8
|
+
*
|
|
9
|
+
* Paystack appends its checkout overlay to `document.body`. A modal Radix
|
|
10
|
+
* dialog sets `pointer-events: none` on that same body and traps focus inside
|
|
11
|
+
* its own content, so the popup arrives on top of the page, looks completely
|
|
12
|
+
* normal, and eats every click and keystroke aimed at it. The fan sees the card
|
|
13
|
+
* form and cannot type in it. Event tickets, courses, coaching, films,
|
|
14
|
+
* donations and offers all open the popup from inside one of these dialogs, so
|
|
15
|
+
* every one of them was unpayable on Paystack.
|
|
16
|
+
*
|
|
17
|
+
* It went unnoticed because the only inline-checkout UI coverage was the
|
|
18
|
+
* payment-link page, which is a plain page with no dialog on it.
|
|
19
|
+
*
|
|
20
|
+
* jsdom rather than `renderToStaticMarkup`: the whole behaviour is mount
|
|
21
|
+
* effects (Radix writes the body style on mount and restores it on unmount), so
|
|
22
|
+
* there is nothing to assert without a real DOM lifecycle.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type ResumeOptions = {
|
|
26
|
+
onSuccess?: (t: { reference?: string }) => void;
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
onLoad?: (r: unknown) => void;
|
|
29
|
+
onError?: (e: unknown) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let captured: ResumeOptions = {};
|
|
33
|
+
|
|
34
|
+
vi.mock("@paystack/inline-js", () => ({
|
|
35
|
+
default: class FakePaystackPop {
|
|
36
|
+
resumeTransaction(_accessCode: string, options: ResumeOptions = {}) {
|
|
37
|
+
captured = options;
|
|
38
|
+
options.onLoad?.({});
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
cancelTransaction() {}
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { openPaystackCheckout } from "../../../utils/paystackCheckout";
|
|
46
|
+
import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle } from "../dialog";
|
|
47
|
+
|
|
48
|
+
/** jsdom refuses real navigation, so `location` is replaced with a plain bag. */
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
captured = {};
|
|
51
|
+
Object.defineProperty(window, "location", {
|
|
52
|
+
value: { href: "https://artist.test/tickets" },
|
|
53
|
+
writable: true,
|
|
54
|
+
configurable: true,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function Checkout({ open, onOpenChange }: { open: boolean; onOpenChange?: (next: boolean) => void }) {
|
|
59
|
+
return (
|
|
60
|
+
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
|
61
|
+
<DialogPortal>
|
|
62
|
+
<DialogOverlay />
|
|
63
|
+
<DialogContent aria-describedby={undefined}>
|
|
64
|
+
<DialogTitle>Tickets</DialogTitle>
|
|
65
|
+
<button type="button">Pay</button>
|
|
66
|
+
</DialogContent>
|
|
67
|
+
</DialogPortal>
|
|
68
|
+
</DialogRoot>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Open the popup the way a pay button does, and leave it open.
|
|
74
|
+
*
|
|
75
|
+
* The attempt is handed back inside an object rather than as the promise
|
|
76
|
+
* itself: `await` flattens a promise returned from an async function, and this
|
|
77
|
+
* one deliberately does not settle until the fan acts.
|
|
78
|
+
*/
|
|
79
|
+
async function openPopup(): Promise<{ attempt: Promise<unknown> }> {
|
|
80
|
+
let attempt: Promise<unknown> = Promise.resolve();
|
|
81
|
+
await act(async () => {
|
|
82
|
+
attempt = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: "https://checkout.paystack.com/abc" });
|
|
83
|
+
// Let the dynamic import of the SDK land.
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
});
|
|
87
|
+
return { attempt };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Close the popup and let the attempt finish, as a dismissal does. */
|
|
91
|
+
async function dismissPopup(attempt: Promise<unknown>): Promise<void> {
|
|
92
|
+
await act(async () => {
|
|
93
|
+
captured.onCancel?.();
|
|
94
|
+
await attempt;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the dialog and the Paystack popup", () => {
|
|
99
|
+
it("locks the page while it is an ordinary modal", () => {
|
|
100
|
+
// The baseline, asserted so the rest of this file cannot pass by the
|
|
101
|
+
// dialog simply never being modal in the first place.
|
|
102
|
+
render(<Checkout open />);
|
|
103
|
+
|
|
104
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("REGRESSION: releases the page the moment the popup opens", async () => {
|
|
108
|
+
render(<Checkout open />);
|
|
109
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
110
|
+
|
|
111
|
+
const { attempt } = await openPopup();
|
|
112
|
+
|
|
113
|
+
// The fan can now reach the card form. This one assertion is the bug.
|
|
114
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
115
|
+
|
|
116
|
+
await dismissPopup(attempt);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("stays out of the way after a dismissal rather than remounting the checkout under the fan", async () => {
|
|
120
|
+
// Radix renders modal and non-modal content as different components, so
|
|
121
|
+
// flipping back would remount everything inside the dialog in front of
|
|
122
|
+
// somebody who just closed the popup and is about to try again.
|
|
123
|
+
render(<Checkout open />);
|
|
124
|
+
const { attempt } = await openPopup();
|
|
125
|
+
|
|
126
|
+
await dismissPopup(attempt);
|
|
127
|
+
|
|
128
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is a normal modal again the next time it opens", async () => {
|
|
132
|
+
// Closed by its owner setting `open` to false, with no `onOpenChange` in
|
|
133
|
+
// sight. That is how the ticket modal closes when a buyer sends their
|
|
134
|
+
// selection to the cart, and watching only `onOpenChange` left those
|
|
135
|
+
// dialogs non-modal for the rest of the visit.
|
|
136
|
+
const { rerender } = render(<Checkout open />);
|
|
137
|
+
const { attempt } = await openPopup();
|
|
138
|
+
await dismissPopup(attempt);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
rerender(<Checkout open={false} />);
|
|
142
|
+
});
|
|
143
|
+
await act(async () => {
|
|
144
|
+
rerender(<Checkout open />);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("REGRESSION: Escape closes the payment window, not the checkout behind it", async () => {
|
|
151
|
+
// Non-modal dialogs still dismiss on Escape, and a fan pressing it means
|
|
152
|
+
// "close Paystack". Taking the checkout down with it loses the order.
|
|
153
|
+
const onOpenChange = vi.fn();
|
|
154
|
+
render(<Checkout open onOpenChange={onOpenChange} />);
|
|
155
|
+
const { attempt } = await openPopup();
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
162
|
+
|
|
163
|
+
await dismissPopup(attempt);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("REGRESSION: a click on Paystack's own backdrop does not close the checkout", async () => {
|
|
167
|
+
// Paystack's overlay is a node in THIS document, outside the dialog
|
|
168
|
+
// content, so a non-modal dialog reads a tap beside the card form as an
|
|
169
|
+
// outside interaction and dismisses.
|
|
170
|
+
const onOpenChange = vi.fn();
|
|
171
|
+
render(<Checkout open onOpenChange={onOpenChange} />);
|
|
172
|
+
const { attempt } = await openPopup();
|
|
173
|
+
|
|
174
|
+
const backdrop = document.createElement("div");
|
|
175
|
+
document.body.appendChild(backdrop);
|
|
176
|
+
await act(async () => {
|
|
177
|
+
fireEvent.pointerDown(backdrop);
|
|
178
|
+
fireEvent.mouseDown(backdrop);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
182
|
+
|
|
183
|
+
await dismissPopup(attempt);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -1,14 +1,149 @@
|
|
|
1
|
-
// Unstyled dialog primitives — thin
|
|
1
|
+
// Unstyled dialog primitives — a thin wrapper over Radix Dialog so the headless
|
|
2
2
|
// blocks get portal + focus-trap + a11y + keyboard for free, with zero styling.
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
//
|
|
4
|
+
// Thin, but no longer a bare re-export: `DialogRoot` and `DialogContent` stand
|
|
5
|
+
// down while a Paystack popup is on screen. See `usePaystackPopupOpen` below
|
|
6
|
+
// for why that is not optional.
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import {
|
|
9
|
+
Root,
|
|
5
10
|
Trigger as DialogTrigger,
|
|
6
11
|
Portal as DialogPortal,
|
|
7
12
|
Overlay as DialogOverlay,
|
|
8
|
-
Content
|
|
13
|
+
Content,
|
|
9
14
|
Close as DialogClose,
|
|
10
15
|
Title as DialogTitle,
|
|
11
16
|
Description as DialogDescription,
|
|
12
17
|
} from "@radix-ui/react-dialog";
|
|
18
|
+
import { isPaystackPopupOpen, subscribeToPaystackPopup } from "../../utils/paystackCheckout";
|
|
13
19
|
|
|
20
|
+
export { DialogTrigger, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription };
|
|
14
21
|
export { Slot } from "@radix-ui/react-slot";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is Paystack's popup on screen right now?
|
|
25
|
+
*
|
|
26
|
+
* Exported for a site that builds its own dialog instead of using these
|
|
27
|
+
* primitives: the popup lives on `document.body`, so ANY modal layer that locks
|
|
28
|
+
* the page will lock the popup out with it.
|
|
29
|
+
*/
|
|
30
|
+
export function usePaystackPopupOpen(): boolean {
|
|
31
|
+
return React.useSyncExternalStore(
|
|
32
|
+
subscribeToPaystackPopup,
|
|
33
|
+
isPaystackPopupOpen,
|
|
34
|
+
// SSR: no popup can be open on the server, and guessing "yes" would render
|
|
35
|
+
// a non-modal dialog that turns modal on hydration.
|
|
36
|
+
() => false,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The dialog root, which STOPS BEING MODAL once a Paystack popup opens inside
|
|
42
|
+
* it.
|
|
43
|
+
*
|
|
44
|
+
* A modal Radix dialog sets `pointer-events: none` on `document.body` and traps
|
|
45
|
+
* focus in its own content. Paystack appends its overlay to the body, outside
|
|
46
|
+
* that content, so both of those apply to the popup: the fan sees the card form
|
|
47
|
+
* on top of everything, clicks it, and nothing happens — the dialog they opened
|
|
48
|
+
* it from is eating every event. Every Paystack pillar on a code website (event
|
|
49
|
+
* tickets, courses, coaching, films, donations, offers) opens the popup from
|
|
50
|
+
* inside one of these, so all six were unpayable.
|
|
51
|
+
*
|
|
52
|
+
* Non-modal content drops both behaviours, which is exactly the amount of
|
|
53
|
+
* getting-out-of-the-way required, and the popup's own overlay is still on top
|
|
54
|
+
* of the page at a z-index nothing here competes with.
|
|
55
|
+
*
|
|
56
|
+
* ## Why it does not switch back when the popup closes
|
|
57
|
+
*
|
|
58
|
+
* Radix renders modal and non-modal content as DIFFERENT components, so
|
|
59
|
+
* flipping the flag remounts everything inside the dialog. Once is free: the
|
|
60
|
+
* popup is covering the screen at that moment and the pillar hooks that hold
|
|
61
|
+
* the checkout state live OUTSIDE the dialog content. Flipping back on dismissal
|
|
62
|
+
* would remount a second time in front of the fan, resetting whatever they had
|
|
63
|
+
* typed into the step they are looking at. So the dialog stays non-modal until
|
|
64
|
+
* it CLOSES, and opens modal again next time.
|
|
65
|
+
*
|
|
66
|
+
* A host that passes `modal={false}` keeps it: this only ever removes modality.
|
|
67
|
+
*/
|
|
68
|
+
export function DialogRoot({
|
|
69
|
+
modal,
|
|
70
|
+
open,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
...props
|
|
73
|
+
}: React.ComponentProps<typeof Root>): React.ReactElement {
|
|
74
|
+
const popupOpen = usePaystackPopupOpen();
|
|
75
|
+
const [stoodDown, setStoodDown] = React.useState(false);
|
|
76
|
+
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (popupOpen) setStoodDown(true);
|
|
79
|
+
}, [popupOpen]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Closing ends the stand-down, so the NEXT time this dialog opens it is an
|
|
83
|
+
* ordinary modal again.
|
|
84
|
+
*
|
|
85
|
+
* Both routes are covered on purpose. A CONTROLLED dialog can be closed by
|
|
86
|
+
* its owner setting `open` to false without `onOpenChange` ever firing (the
|
|
87
|
+
* ticket modal does exactly that when the buyer sends a selection to the
|
|
88
|
+
* cart), and an UNCONTROLLED one has no `open` prop to watch.
|
|
89
|
+
*/
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (open === false) setStoodDown(false);
|
|
92
|
+
}, [open]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Root
|
|
96
|
+
{...props}
|
|
97
|
+
open={open}
|
|
98
|
+
modal={modal === false ? false : !stoodDown}
|
|
99
|
+
onOpenChange={(next) => {
|
|
100
|
+
if (!next) setStoodDown(false);
|
|
101
|
+
onOpenChange?.(next);
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The dialog body, which refuses to fight the Paystack popup for focus or for
|
|
109
|
+
* the fan's clicks.
|
|
110
|
+
*
|
|
111
|
+
* Three defaults have to be suspended while the popup is up, and all three are
|
|
112
|
+
* the dialog doing its job in a situation where its job is wrong:
|
|
113
|
+
*
|
|
114
|
+
* - **Auto-focus on open.** A non-modal remount focuses its first control,
|
|
115
|
+
* which would yank the caret out of the card field the fan is typing in.
|
|
116
|
+
* - **Dismiss on outside interaction.** Paystack's backdrop is a click in THIS
|
|
117
|
+
* document, outside this content, so closing the popup by tapping beside it
|
|
118
|
+
* would take the checkout down with it.
|
|
119
|
+
* - **Dismiss on Escape.** Same thing with the key that means "close the
|
|
120
|
+
* payment window".
|
|
121
|
+
*
|
|
122
|
+
* Each host handler still runs; the suspension is applied after it, so a
|
|
123
|
+
* surface that wants its own behaviour keeps it everywhere except here.
|
|
124
|
+
*/
|
|
125
|
+
export const DialogContent = React.forwardRef<
|
|
126
|
+
React.ElementRef<typeof Content>,
|
|
127
|
+
React.ComponentPropsWithoutRef<typeof Content>
|
|
128
|
+
>(function DialogContent({ onOpenAutoFocus, onInteractOutside, onEscapeKeyDown, ...props }, ref) {
|
|
129
|
+
const popupOpen = usePaystackPopupOpen();
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Content
|
|
133
|
+
{...props}
|
|
134
|
+
ref={ref}
|
|
135
|
+
onOpenAutoFocus={(event) => {
|
|
136
|
+
onOpenAutoFocus?.(event);
|
|
137
|
+
if (popupOpen) event.preventDefault();
|
|
138
|
+
}}
|
|
139
|
+
onInteractOutside={(event) => {
|
|
140
|
+
onInteractOutside?.(event);
|
|
141
|
+
if (popupOpen) event.preventDefault();
|
|
142
|
+
}}
|
|
143
|
+
onEscapeKeyDown={(event) => {
|
|
144
|
+
onEscapeKeyDown?.(event);
|
|
145
|
+
if (popupOpen) event.preventDefault();
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
});
|
package/src/ui/index.ts
CHANGED
|
@@ -200,7 +200,10 @@ export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
|
|
|
200
200
|
// five gate states each strand somebody if drawn as one of the others.
|
|
201
201
|
export { FilmCatalog, rentalWindowLabel, type FilmCatalogProps } from "./styled/FilmCatalog";
|
|
202
202
|
export { FilmDetail, type FilmDetailProps } from "./styled/FilmDetail";
|
|
203
|
-
|
|
203
|
+
// `FilmRentalList` is the library WITHOUT its page heading: the rows on their
|
|
204
|
+
// own, for a surface that supplies its own title (the account dashboard's
|
|
205
|
+
// Rentals tab does exactly this). Same rows, same live/expired rule.
|
|
206
|
+
export { FilmLibrary, FilmRentalList, type FilmLibraryProps, type FilmRentalListProps } from "./styled/FilmLibrary";
|
|
204
207
|
export { FilmWatch, type FilmWatchProps, type FilmPlayerRenderProps } from "./styled/FilmWatch";
|
|
205
208
|
export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
|
|
206
209
|
export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
useSavedPosts,
|
|
19
19
|
useNotificationPreferences,
|
|
20
20
|
useUpdateNotificationPreference,
|
|
21
|
+
useMyFilmRentals,
|
|
21
22
|
useCancelMembership,
|
|
22
23
|
useMembershipAccess,
|
|
23
24
|
useOpenBillingPortal,
|
|
@@ -42,6 +43,7 @@ import { AddToCalendar } from "./AddToCalendar";
|
|
|
42
43
|
import { WalletPassButtons } from "./WalletPassButtons";
|
|
43
44
|
import { TicketTransferPanel } from "./TicketTransfer";
|
|
44
45
|
import { CartLineOptions } from "./CartLineOptions";
|
|
46
|
+
import { FilmRentalList } from "./FilmLibrary";
|
|
45
47
|
|
|
46
48
|
export const ACCOUNT_TABS = [
|
|
47
49
|
"membership",
|
|
@@ -54,6 +56,13 @@ export const ACCOUNT_TABS = [
|
|
|
54
56
|
// "order" — the Orders tab covers product orders only — so it needs its own
|
|
55
57
|
// tab or it is unreachable, which is what it was.
|
|
56
58
|
"bookings",
|
|
59
|
+
// Films and series this fan has rented. Next to the other purchase surfaces
|
|
60
|
+
// because it answers the same question they do: what did I buy from this
|
|
61
|
+
// artist, and can I use it right now?
|
|
62
|
+
//
|
|
63
|
+
// Drawn only when the fan actually holds a rental (see `visibleTabs`), so the
|
|
64
|
+
// tab does not appear on the account page of every site that never sells one.
|
|
65
|
+
"rentals",
|
|
57
66
|
"saved",
|
|
58
67
|
"account",
|
|
59
68
|
"notifications",
|
|
@@ -70,6 +79,7 @@ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
|
|
|
70
79
|
tickets: "forge.account_dashboard.tab_tickets",
|
|
71
80
|
waitlist: "forge.account_dashboard.tab_waitlist",
|
|
72
81
|
bookings: "forge.account_dashboard.tab_bookings",
|
|
82
|
+
rentals: "forge.account_dashboard.tab_rentals",
|
|
73
83
|
saved: "forge.account_dashboard.tab_saved",
|
|
74
84
|
account: "forge.account_dashboard.tab_account",
|
|
75
85
|
notifications: "forge.account_dashboard.tab_notifications",
|
|
@@ -98,6 +108,15 @@ export interface AccountDashboardProps {
|
|
|
98
108
|
* member a room they get bounced out of, which is worse than no link at all.
|
|
99
109
|
*/
|
|
100
110
|
onNavigateCommunity?: () => void;
|
|
111
|
+
/**
|
|
112
|
+
* Navigate to a film route from the Rentals tab (watch, resume, rent again).
|
|
113
|
+
*
|
|
114
|
+
* Takes the href rather than being one callback per destination, because a
|
|
115
|
+
* rental row points at a title AND at the episode the fan got to, so the
|
|
116
|
+
* target is data rather than a fixed page. Left out, the row falls back to a
|
|
117
|
+
* full-page visit, which works on any site and is merely slower.
|
|
118
|
+
*/
|
|
119
|
+
onNavigateFilm?: (href: string) => void;
|
|
101
120
|
/**
|
|
102
121
|
* Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
|
|
103
122
|
* (which honors the visitor's selected currency + tenant exchange rates), so the
|
|
@@ -164,6 +183,7 @@ interface TabContext {
|
|
|
164
183
|
currency?: string;
|
|
165
184
|
onNavigateMembership?: () => void;
|
|
166
185
|
onNavigateCommunity?: () => void;
|
|
186
|
+
onNavigateFilm?: (href: string) => void;
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
// ---- page -------------------------------------------------------------------
|
|
@@ -178,6 +198,7 @@ export function AccountDashboard({
|
|
|
178
198
|
onTabChange,
|
|
179
199
|
onNavigateMembership,
|
|
180
200
|
onNavigateCommunity,
|
|
201
|
+
onNavigateFilm,
|
|
181
202
|
formatAmount,
|
|
182
203
|
formatDate,
|
|
183
204
|
currency,
|
|
@@ -188,6 +209,17 @@ export function AccountDashboard({
|
|
|
188
209
|
const { t } = useCardStyles();
|
|
189
210
|
const tr = useForgeT();
|
|
190
211
|
const { data: siteConfig } = useSiteConfig();
|
|
212
|
+
/**
|
|
213
|
+
* Does this fan hold any rental at all? That is the whole gate on the Rentals
|
|
214
|
+
* tab, on the same principle as the community link above: the server's own
|
|
215
|
+
* answer decides, and a surface with nothing behind it is not drawn.
|
|
216
|
+
*
|
|
217
|
+
* Film rentals are off by default and per profile, so on most sites this read
|
|
218
|
+
* comes back empty for ever and the tab never appears. It is the SAME query
|
|
219
|
+
* `FilmRentalList` runs inside the tab (same key, same arguments), so
|
|
220
|
+
* react-query serves both from one request rather than two.
|
|
221
|
+
*/
|
|
222
|
+
const { data: rentals } = useMyFilmRentals(user?.id);
|
|
191
223
|
|
|
192
224
|
const fmtAmount = useAmountFormatter(formatAmount);
|
|
193
225
|
const fmtDate = formatDate ?? defaultFormatDate;
|
|
@@ -200,8 +232,18 @@ export function AccountDashboard({
|
|
|
200
232
|
currency: currency ?? siteConfig?.currency,
|
|
201
233
|
onNavigateMembership,
|
|
202
234
|
onNavigateCommunity,
|
|
235
|
+
onNavigateFilm,
|
|
203
236
|
};
|
|
204
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The strip hides Rentals until there is something in it. The one exception
|
|
240
|
+
* is the tab currently being shown, so a link straight to `?tab=rentals` (a
|
|
241
|
+
* receipt email, a bookmark) does not land on a page whose strip disowns the
|
|
242
|
+
* panel underneath it.
|
|
243
|
+
*/
|
|
244
|
+
const hasRentals = (rentals?.total ?? 0) > 0;
|
|
245
|
+
const visibleTabs = ACCOUNT_TABS.filter((key) => key !== "rentals" || hasRentals || tab === "rentals");
|
|
246
|
+
|
|
205
247
|
if (!user) return <Loading fullPage />;
|
|
206
248
|
|
|
207
249
|
return (
|
|
@@ -222,7 +264,7 @@ export function AccountDashboard({
|
|
|
222
264
|
</div>
|
|
223
265
|
|
|
224
266
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 24 }}>
|
|
225
|
-
{
|
|
267
|
+
{visibleTabs.map((tabKey) => (
|
|
226
268
|
<button
|
|
227
269
|
key={tabKey}
|
|
228
270
|
onClick={() => onTabChange?.(tabKey)}
|
|
@@ -247,6 +289,7 @@ export function AccountDashboard({
|
|
|
247
289
|
{tab === "tickets" && <TicketsTab ctx={ctx} />}
|
|
248
290
|
{tab === "waitlist" && <WaitlistTab ctx={ctx} />}
|
|
249
291
|
{tab === "bookings" && <BookingsTab ctx={ctx} />}
|
|
292
|
+
{tab === "rentals" && <RentalsTab ctx={ctx} />}
|
|
250
293
|
{tab === "saved" && <SavedTab />}
|
|
251
294
|
{tab === "account" && <AccountTab ctx={ctx} />}
|
|
252
295
|
{tab === "notifications" && <NotificationsTab />}
|
|
@@ -1286,6 +1329,35 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1286
1329
|
);
|
|
1287
1330
|
}
|
|
1288
1331
|
|
|
1332
|
+
// ---- Rentals tab ------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Films and series this fan has rented, live and expired.
|
|
1336
|
+
*
|
|
1337
|
+
* `/i/films/library` already answered this, and nothing on the account page led
|
|
1338
|
+
* to it: a fan who rented a film found their tickets, their orders and their
|
|
1339
|
+
* sessions here and no sign that the rental existed. This is that page's list,
|
|
1340
|
+
* rendered in the tab strip where a buyer looks for a purchase.
|
|
1341
|
+
*
|
|
1342
|
+
* The affordance per row comes from the SERVER's `isLive`, not from arithmetic
|
|
1343
|
+
* repeated here: a running window offers Watch (Resume, when there is progress
|
|
1344
|
+
* to resume), and a window that has closed or been refunded offers Rent again.
|
|
1345
|
+
* That decision lives in one place, `FilmRentalList`, and both surfaces read it.
|
|
1346
|
+
*/
|
|
1347
|
+
function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
1348
|
+
const { t, card } = useCardStyles();
|
|
1349
|
+
const tr = useForgeT();
|
|
1350
|
+
|
|
1351
|
+
return (
|
|
1352
|
+
<div style={card}>
|
|
1353
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1354
|
+
{tr("forge.account_dashboard.rentals_title")}
|
|
1355
|
+
</h2>
|
|
1356
|
+
<FilmRentalList navigate={ctx.onNavigateFilm} />
|
|
1357
|
+
</div>
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1289
1361
|
// ---- Orders tab -------------------------------------------------------------
|
|
1290
1362
|
|
|
1291
1363
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../../data/queries/useFilms";
|
|
12
12
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
13
13
|
|
|
14
|
-
export interface
|
|
14
|
+
export interface FilmRentalListProps {
|
|
15
15
|
/** Where films live. Default `/i/films`. */
|
|
16
16
|
basePath?: string;
|
|
17
17
|
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
@@ -20,27 +20,30 @@ export interface FilmLibraryProps {
|
|
|
20
20
|
style?: CSSProperties;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** The page takes exactly what the list takes: it adds a heading, nothing else. */
|
|
24
|
+
export type FilmLibraryProps = FilmRentalListProps;
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
|
-
* The
|
|
25
|
-
* has run out.
|
|
27
|
+
* The rentals themselves, with no page furniture around them.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Split out of `FilmLibrary` so the account dashboard's Rentals tab renders the
|
|
30
|
+
* SAME rows as `/i/films/library` rather than a second implementation of "is
|
|
31
|
+
* this window still open". Two answers to that question is one answer too many:
|
|
32
|
+
* the divergent copy would appear on whichever surface was edited second, and
|
|
33
|
+
* the fan would be told two different things about the same rental.
|
|
31
34
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* It fetches its own data. `useMyFilmRentals` is keyed on the account, so a host
|
|
36
|
+
* that already read it (the dashboard does, to decide whether to draw the tab at
|
|
37
|
+
* all) shares one request through react-query rather than making a second.
|
|
35
38
|
*/
|
|
36
|
-
export function
|
|
39
|
+
export function FilmRentalList({ basePath = "/i/films", navigate, className, style }: FilmRentalListProps) {
|
|
37
40
|
const t = useForgeT();
|
|
38
41
|
const tokens = useThemeTokens();
|
|
39
42
|
const { user, isInitialized } = usePublicAuth();
|
|
40
43
|
const { data, isLoading } = useMyFilmRentals(user?.id);
|
|
41
44
|
const go = navigate ?? ((href: string) => window.location.assign(href));
|
|
42
45
|
|
|
43
|
-
if (!isInitialized || isLoading) return <Loading
|
|
46
|
+
if (!isInitialized || isLoading) return <Loading />;
|
|
44
47
|
|
|
45
48
|
const rentals = data?.data ?? [];
|
|
46
49
|
|
|
@@ -61,23 +64,11 @@ export function FilmLibrary({ basePath = "/i/films", navigate, className, style
|
|
|
61
64
|
display: "flex",
|
|
62
65
|
flexDirection: "column",
|
|
63
66
|
gap: 16,
|
|
64
|
-
maxWidth: 900,
|
|
65
|
-
margin: "0 auto",
|
|
66
67
|
color: tokens.text,
|
|
67
68
|
fontFamily: tokens.fontFamily,
|
|
68
69
|
...style,
|
|
69
70
|
}}
|
|
70
71
|
>
|
|
71
|
-
<h1
|
|
72
|
-
style={{
|
|
73
|
-
fontSize: 24,
|
|
74
|
-
fontWeight: 800,
|
|
75
|
-
fontFamily: tokens.headingFontFamily || tokens.fontFamily,
|
|
76
|
-
}}
|
|
77
|
-
>
|
|
78
|
-
{t("forge.film_library.heading")}
|
|
79
|
-
</h1>
|
|
80
|
-
|
|
81
72
|
{rentals.length === 0 && (
|
|
82
73
|
<div style={notice}>
|
|
83
74
|
<p style={{ marginBottom: 12 }}>{t("forge.film_library.empty")}</p>
|
|
@@ -107,6 +98,52 @@ export function FilmLibrary({ basePath = "/i/films", navigate, className, style
|
|
|
107
98
|
);
|
|
108
99
|
}
|
|
109
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The fan's own rentals as a PAGE: what is running, what has not been started,
|
|
103
|
+
* and what has run out.
|
|
104
|
+
*
|
|
105
|
+
* Expired rentals are here deliberately. The library is where a fan re-rents,
|
|
106
|
+
* and a rental that vanishes on expiry reads as "you were robbed" rather than
|
|
107
|
+
* "that ended" - so an expired row keeps its title, states plainly that the
|
|
108
|
+
* window closed, and offers the way back.
|
|
109
|
+
*
|
|
110
|
+
* A row states one fact the reader must act on, so it stays visible rather than
|
|
111
|
+
* going behind a `?`: how long is left. There is no "what was bought" line any
|
|
112
|
+
* more, because a rental is always the whole title and the title is the row.
|
|
113
|
+
*/
|
|
114
|
+
export function FilmLibrary({ basePath, navigate, className, style }: FilmLibraryProps) {
|
|
115
|
+
const t = useForgeT();
|
|
116
|
+
const tokens = useThemeTokens();
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className={className}
|
|
121
|
+
style={{
|
|
122
|
+
display: "flex",
|
|
123
|
+
flexDirection: "column",
|
|
124
|
+
gap: 16,
|
|
125
|
+
maxWidth: 900,
|
|
126
|
+
margin: "0 auto",
|
|
127
|
+
color: tokens.text,
|
|
128
|
+
fontFamily: tokens.fontFamily,
|
|
129
|
+
...style,
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<h1
|
|
133
|
+
style={{
|
|
134
|
+
fontSize: 24,
|
|
135
|
+
fontWeight: 800,
|
|
136
|
+
fontFamily: tokens.headingFontFamily || tokens.fontFamily,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{t("forge.film_library.heading")}
|
|
140
|
+
</h1>
|
|
141
|
+
|
|
142
|
+
<FilmRentalList basePath={basePath} navigate={navigate} />
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
/** Where the fan got to in the episode they should resume, or null. */
|
|
111
148
|
function resumeTarget(rental: MyFilmRental): { episodeId: string; entry: FilmWatchProgressEntry } | null {
|
|
112
149
|
// The furthest-along INCOMPLETE episode: resuming a finished one is how a
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { MyFilmRental } from "../../../data/queries/useFilms";
|
|
8
|
+
import { AccountDashboard, type AccountTabKey } from "../AccountDashboard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rentals on the account page.
|
|
12
|
+
*
|
|
13
|
+
* A fan who rented a film could find their tickets, their orders and their
|
|
14
|
+
* coaching sessions on this page and no trace of the rental - `/i/films/library`
|
|
15
|
+
* held it, and nothing on the account page pointed there. So the tab exists, and
|
|
16
|
+
* these are the two things it must get right:
|
|
17
|
+
*
|
|
18
|
+
* LIVE - the window is open, so the row offers the way in (Resume when
|
|
19
|
+
* there is progress to resume, Watch otherwise).
|
|
20
|
+
* NOT LIVE - expired or refunded, so the row offers Rent again and NOT a
|
|
21
|
+
* Watch button that leads to a player which would refuse them.
|
|
22
|
+
*
|
|
23
|
+
* `isLive` is the SERVER's answer (`msRemaining`, `expiresAt`, `revokedAt` are
|
|
24
|
+
* all decided there). Nothing here recomputes it, and these specs assert the
|
|
25
|
+
* rendering of that flag rather than any clock arithmetic in the browser.
|
|
26
|
+
*
|
|
27
|
+
* Rendered through `react-dom/server`, like the community spec beside it: no
|
|
28
|
+
* DOM and no effects, so the rentals query is SEEDED into the react-query cache
|
|
29
|
+
* rather than mocked. That keeps the real `useMyFilmRentals` in the path, and a
|
|
30
|
+
* rename of its query key fails here.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const PROFILE_ID = "profile-1";
|
|
34
|
+
const ACCOUNT_ID = "account-1";
|
|
35
|
+
|
|
36
|
+
/** The key `useMyFilmRentals(accountId)` reads, at its default page and limit. */
|
|
37
|
+
const RENTALS_KEY = ["my-film-rentals", ACCOUNT_ID, PROFILE_ID, 1, 20];
|
|
38
|
+
|
|
39
|
+
const rental = (overrides: Partial<MyFilmRental> = {}): MyFilmRental => ({
|
|
40
|
+
id: "rental-1",
|
|
41
|
+
filmId: "film-1",
|
|
42
|
+
status: "active",
|
|
43
|
+
amountCents: 500,
|
|
44
|
+
currency: "USD",
|
|
45
|
+
rentalDurationHours: 48,
|
|
46
|
+
rentalOptionId: "option-1",
|
|
47
|
+
paidAt: "2026-08-01T00:00:00.000Z",
|
|
48
|
+
startBy: "2026-09-01T00:00:00.000Z",
|
|
49
|
+
firstPlayedAt: "2026-08-02T00:00:00.000Z",
|
|
50
|
+
expiresAt: "2026-08-04T00:00:00.000Z",
|
|
51
|
+
refundedAt: null,
|
|
52
|
+
revokedAt: null,
|
|
53
|
+
isLive: true,
|
|
54
|
+
msRemaining: 3 * 3600_000,
|
|
55
|
+
windowMs: 48 * 3600_000,
|
|
56
|
+
film: {
|
|
57
|
+
id: "film-1",
|
|
58
|
+
title: "The Long Goodbye",
|
|
59
|
+
slug: "the-long-goodbye",
|
|
60
|
+
kind: "film",
|
|
61
|
+
status: "published",
|
|
62
|
+
archivedAt: null,
|
|
63
|
+
cover: null,
|
|
64
|
+
},
|
|
65
|
+
progress: [],
|
|
66
|
+
...overrides,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const fan = {
|
|
70
|
+
id: ACCOUNT_ID,
|
|
71
|
+
email: "fan@example.com",
|
|
72
|
+
firstName: "Fan",
|
|
73
|
+
lastName: "Person",
|
|
74
|
+
kind: "fan",
|
|
75
|
+
status: "active",
|
|
76
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
77
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
78
|
+
membership: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function render(opts: { rentals: MyFilmRental[] | null; tab?: AccountTabKey }): string {
|
|
82
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
83
|
+
if (opts.rentals) {
|
|
84
|
+
queryClient.setQueryData(RENTALS_KEY, {
|
|
85
|
+
data: opts.rentals,
|
|
86
|
+
total: opts.rentals.length,
|
|
87
|
+
page: 1,
|
|
88
|
+
limit: 20,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return renderToStaticMarkup(
|
|
93
|
+
<QueryClientProvider client={queryClient}>
|
|
94
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
95
|
+
<PublicAuthContext.Provider
|
|
96
|
+
value={
|
|
97
|
+
{
|
|
98
|
+
user: fan,
|
|
99
|
+
isInitialized: true,
|
|
100
|
+
currencies: null,
|
|
101
|
+
userSelectedCurrency: "USD",
|
|
102
|
+
logout: () => {},
|
|
103
|
+
} as never
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
<ForgeThemeProvider
|
|
107
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
108
|
+
>
|
|
109
|
+
<AccountDashboard tab={opts.tab ?? "rentals"} onNavigateMembership={() => {}} />
|
|
110
|
+
</ForgeThemeProvider>
|
|
111
|
+
</PublicAuthContext.Provider>
|
|
112
|
+
</ForgeClientProvider>
|
|
113
|
+
</QueryClientProvider>,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("AccountDashboard: the fan's rentals", () => {
|
|
118
|
+
it("REGRESSION: a running rental is reachable from the account page", () => {
|
|
119
|
+
// The gap this closes. The rental existed, the player existed, and the
|
|
120
|
+
// account page mentioned neither.
|
|
121
|
+
const html = render({ rentals: [rental()] });
|
|
122
|
+
|
|
123
|
+
expect(html).toContain("Rentals");
|
|
124
|
+
expect(html).toContain("The Long Goodbye");
|
|
125
|
+
expect(html).toContain("Watch");
|
|
126
|
+
// The window is state the fan acts on, so it is on the row rather than
|
|
127
|
+
// behind a `?`.
|
|
128
|
+
expect(html).toContain("3h 0m left");
|
|
129
|
+
expect(html).not.toContain("Rent again");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("offers to resume the episode the fan stopped in the middle of", () => {
|
|
133
|
+
const html = render({
|
|
134
|
+
rentals: [rental({ progress: [{ episodeId: "episode-1", positionSec: 900, completedAt: null }] })],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(html).toContain("Resume");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("REGRESSION: an expired rental offers Rent again, never Watch", () => {
|
|
141
|
+
// Drawing Watch here walks the fan into a player that refuses them: the
|
|
142
|
+
// window is the server's to judge and it has closed.
|
|
143
|
+
const html = render({
|
|
144
|
+
rentals: [rental({ isLive: false, msRemaining: 0, expiresAt: "2026-08-04T00:00:00.000Z" })],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(html).toContain("Rent again");
|
|
148
|
+
expect(html).toContain("Your window has closed.");
|
|
149
|
+
expect(html).not.toContain(">Watch<");
|
|
150
|
+
expect(html).not.toContain(">Resume<");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("says a refunded rental was refunded rather than that it ran out", () => {
|
|
154
|
+
// Two different things happened, and "your window has closed" over a refund
|
|
155
|
+
// reads as the artist keeping the money.
|
|
156
|
+
const html = render({
|
|
157
|
+
rentals: [rental({ isLive: false, msRemaining: null, refundedAt: "2026-08-03T00:00:00.000Z" })],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(html).toContain("Refunded.");
|
|
161
|
+
expect(html).toContain("Rent again");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps a rental that has not been started apart from one that is running", () => {
|
|
165
|
+
// The clock starts at first play, so this fan has lost nothing by waiting.
|
|
166
|
+
// Saying "0m left" here would be a lie that costs them the film.
|
|
167
|
+
const html = render({ rentals: [rental({ firstPlayedAt: null, msRemaining: null, expiresAt: null })] });
|
|
168
|
+
|
|
169
|
+
expect(html).toContain("Not started.");
|
|
170
|
+
expect(html).toContain("Watch");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("does not draw the tab on a site where this fan holds no rental", () => {
|
|
174
|
+
// Film rentals are off by default and per profile. An always-empty tab on
|
|
175
|
+
// every account page is noise on every site that never sells one.
|
|
176
|
+
const html = render({ rentals: [], tab: "membership" });
|
|
177
|
+
|
|
178
|
+
expect(html).not.toContain("Rentals");
|
|
179
|
+
// The rest of the strip is untouched, so this is the one tab missing rather
|
|
180
|
+
// than the dashboard failing to draw.
|
|
181
|
+
expect(html).toContain("Tickets");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("stays quiet while the rentals query has not answered yet", () => {
|
|
185
|
+
// First paint has no cached answer. Drawing the tab on a guess flashes it
|
|
186
|
+
// and then withdraws it from fans who never rented anything.
|
|
187
|
+
const html = render({ rentals: null, tab: "membership" });
|
|
188
|
+
|
|
189
|
+
expect(html).not.toContain("Rentals");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("keeps the tab drawn when it is the one being shown, even with nothing in it", () => {
|
|
193
|
+
// A receipt email links straight at `?tab=rentals`. A strip that disowns the
|
|
194
|
+
// panel underneath it is worse than an empty tab.
|
|
195
|
+
const html = render({ rentals: [], tab: "rentals" });
|
|
196
|
+
|
|
197
|
+
expect(html).toContain("Rentals");
|
|
198
|
+
expect(html).toContain("You have not rented anything yet.");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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
|