@tribe-nest/forge 3.35.0 → 3.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.35.0",
3
+ "version": "3.37.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
@@ -22,7 +22,6 @@
22
22
  "forge.account_dashboard.cancelled": "Storniert",
23
23
  "forge.account_dashboard.cancelling": "Wird storniert…",
24
24
  "forge.account_dashboard.close": "Schließen",
25
- "forge.account_dashboard.community_spaces": "Community-Bereiche",
26
25
  "forge.account_dashboard.confirm_password": "Neues Passwort bestätigen",
27
26
  "forge.account_dashboard.current_password": "Aktuelles Passwort",
28
27
  "forge.account_dashboard.data_export_description": "Exportiere eine Kopie deiner persönlichen Daten.",
@@ -37,6 +36,7 @@
37
36
  "forge.account_dashboard.last_name": "Nachname",
38
37
  "forge.account_dashboard.leave_waitlist": "Warteliste verlassen",
39
38
  "forge.account_dashboard.log_out": "Abmelden",
39
+ "forge.account_dashboard.member_area": "Mitgliederbereich",
40
40
  "forge.account_dashboard.membership_cancel_error": "Wir konnten deine Mitgliedschaft nicht kündigen. Bitte versuche es erneut oder kontaktiere uns.",
41
41
  "forge.account_dashboard.membership_change": "Ändern",
42
42
  "forge.account_dashboard.membership_empty": "Du hast keine aktive Mitgliedschaft.",
@@ -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",
@@ -937,6 +939,9 @@
937
939
  "forge.membership_checkout.title": "{tier_name} beitreten",
938
940
  "forge.membership_checkout.total": "Gesamt",
939
941
  "forge.membership_checkout.view_memberships": "Mitgliedschaften ansehen",
942
+ "forge.membership_tier_callout.benefits_label": "Vorteile:",
943
+ "forge.membership_tier_callout.join_title": "{tier_name} beitreten",
944
+ "forge.membership_tier_callout.more_benefits": "+{count} weitere Vorteile",
940
945
  "forge.membership_tiers.empty_title": "Keine Mitgliedschaften verfügbar.",
941
946
  "forge.membership_tiers.heading": "Mitgliedschaft",
942
947
  "forge.membership_tiers.price_free": "Kostenlos",
package/src/i18n/en.json CHANGED
@@ -22,7 +22,6 @@
22
22
  "forge.account_dashboard.cancelled": "Cancelled",
23
23
  "forge.account_dashboard.cancelling": "Cancelling…",
24
24
  "forge.account_dashboard.close": "Close",
25
- "forge.account_dashboard.community_spaces": "Community spaces",
26
25
  "forge.account_dashboard.confirm_password": "Confirm new password",
27
26
  "forge.account_dashboard.current_password": "Current password",
28
27
  "forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
@@ -37,6 +36,7 @@
37
36
  "forge.account_dashboard.last_name": "Last name",
38
37
  "forge.account_dashboard.leave_waitlist": "Leave waitlist",
39
38
  "forge.account_dashboard.log_out": "Log out",
39
+ "forge.account_dashboard.member_area": "Member Area",
40
40
  "forge.account_dashboard.membership_cancel_error": "We couldn't cancel your membership. Please try again or contact us.",
41
41
  "forge.account_dashboard.membership_change": "Change",
42
42
  "forge.account_dashboard.membership_empty": "You don't have an active membership.",
@@ -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",
@@ -937,6 +939,9 @@
937
939
  "forge.membership_checkout.title": "Join {tier_name}",
938
940
  "forge.membership_checkout.total": "Total",
939
941
  "forge.membership_checkout.view_memberships": "View memberships",
942
+ "forge.membership_tier_callout.benefits_label": "Benefits:",
943
+ "forge.membership_tier_callout.join_title": "Join {tier_name}",
944
+ "forge.membership_tier_callout.more_benefits": "+{count} more benefits",
940
945
  "forge.membership_tiers.empty_title": "No membership tiers available.",
941
946
  "forge.membership_tiers.heading": "Membership",
942
947
  "forge.membership_tiers.price_free": "Free",
@@ -0,0 +1,29 @@
1
+ import type { ForgeT } from "../../i18n";
2
+ import type { MembershipTier } from "../../types/models";
3
+ import { cycleFloor, offeredCycles } from "./membershipPwyw";
4
+
5
+ /**
6
+ * The one sentence that prices a membership tier.
7
+ *
8
+ * Shared rather than copied, because the rule it encodes was a bug: a tier
9
+ * priced on ONE cycle has to be advertised on that cycle. Reading the monthly
10
+ * minimum unconditionally printed "Pay what you want from $0/mo" on a
11
+ * yearly-only tier, which reads as free and is the opposite of the truth. A
12
+ * second copy of this in the next surface that prices a tier would be the copy
13
+ * that misses the next fix.
14
+ *
15
+ * The keys stay under `forge.membership_tiers.*`: this is one sentence about one
16
+ * thing, and every surface that shows a tier price must say it the same way, so
17
+ * an operator editing the copy edits it once.
18
+ */
19
+ export function membershipPriceLabel(tier: MembershipTier, t: ForgeT, fmt: (amount: number) => string): string {
20
+ const cycles = offeredCycles(tier);
21
+ if (tier.payWhatYouWant) {
22
+ return cycles.month
23
+ ? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
24
+ : t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
25
+ }
26
+ if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
27
+ if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
28
+ return t("forge.membership_tiers.price_free");
29
+ }
@@ -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
+ });