@tribe-nest/forge 3.35.0 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +6 -1
- package/src/i18n/en.json +6 -1
- package/src/ui/format/membershipPriceLabel.ts +29 -0
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +136 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +18 -4
- package/src/ui/index.ts +5 -1
- package/src/ui/styled/AccountDashboard.tsx +87 -16
- package/src/ui/styled/FilmLibrary.tsx +61 -24
- package/src/ui/styled/LoginForm.tsx +15 -1
- package/src/ui/styled/MembershipTierCallout.tsx +91 -0
- package/src/ui/styled/MembershipTiers.tsx +5 -15
- package/src/ui/styled/SignupForm.tsx +10 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +49 -34
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +126 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
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
|
@@ -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
|
|
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
|
+
});
|