@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.35.0",
3
+ "version": "3.36.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 res = await client.post(path as string, { ...body, profileId, returnUrl, ...(override ?? {}) });
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
- if (!hasPaystackSession(session)) return "unavailable";
92
- return openPaystackCheckout(session, {
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 re-export of Radix Dialog so the headless
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
- export {
4
- Root as DialogRoot,
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 as DialogContent,
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
- export { FilmLibrary, type FilmLibraryProps } from "./styled/FilmLibrary";
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
- {ACCOUNT_TABS.map((tabKey) => (
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 FilmLibraryProps {
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 fan's own rentals: what is running, what has not been started, and what
25
- * has run out.
27
+ * The rentals themselves, with no page furniture around them.
26
28
  *
27
- * Expired rentals are here deliberately. The library is where a fan re-rents,
28
- * and a rental that vanishes on expiry reads as "you were robbed" rather than
29
- * "that ended" - so an expired row keeps its title, states plainly that the
30
- * window closed, and offers the way back.
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
- * A row states one fact the reader must act on, so it stays visible rather than
33
- * going behind a `?`: how long is left. There is no "what was bought" line any
34
- * more, because a rental is always the whole title and the title is the row.
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 FilmLibrary({ basePath = "/i/films", navigate, className, style }: FilmLibraryProps) {
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 fullPage />;
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
+ });
@@ -84,6 +84,9 @@
84
84
  .order-1 {
85
85
  order: 1;
86
86
  }
87
+ .order-2 {
88
+ order: 2;
89
+ }
87
90
  .container {
88
91
  width: 100%;
89
92
  @media (width >= 40rem) {
@@ -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