@tribe-nest/forge 3.22.0 → 3.23.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 +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +16 -0
- package/src/data/queries/useMyBookings.ts +25 -0
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +52 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +4 -4
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +2 -2
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/useCheckout.ts +72 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +70 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +14 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +22 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +73 -4
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +25 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +3 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +1 -1
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +135 -21
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +2 -2
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/OfferButton.tsx +1 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +1 -1
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +3 -3
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Paystack inline checkout helper: the ONE place Forge knows the popup
|
|
6
|
+
* exists, so it is the one place these rules are enforced.
|
|
7
|
+
*
|
|
8
|
+
* What is asserted here is what a fan actually experiences:
|
|
9
|
+
*
|
|
10
|
+
* - the modal opening at all, and paying continuing down the SAME return leg
|
|
11
|
+
* the hosted redirect used (reference and trxref on the URL, so no finalise
|
|
12
|
+
* page has to change);
|
|
13
|
+
* - closing the popup being a retryable non-event rather than a failed order;
|
|
14
|
+
* - and the three ways the modal can fail to appear (a blocked script, a
|
|
15
|
+
* throwing `resumeTransaction`, and a silent no-show), each ending on the
|
|
16
|
+
* hosted page rather than on a dead button.
|
|
17
|
+
*
|
|
18
|
+
* The last of those is the one worth a test: a fan looking at a button that did
|
|
19
|
+
* nothing is a lost sale that reports no error anywhere.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
type ResumeOptions = {
|
|
23
|
+
onSuccess?: (t: { reference?: string; trxref?: string }) => void;
|
|
24
|
+
onCancel?: () => void;
|
|
25
|
+
onLoad?: (r: unknown) => void;
|
|
26
|
+
onError?: (e: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Set per test to drive the fake popup. */
|
|
30
|
+
let resumeBehaviour: (accessCode: string, options: ResumeOptions) => void = () => {};
|
|
31
|
+
const resumeCalls: { accessCode: string }[] = [];
|
|
32
|
+
|
|
33
|
+
// The blocked-script case gets its own file (`paystackCheckoutBlocked.spec.ts`)
|
|
34
|
+
// because a `vi.mock` factory is evaluated once per module registry, so the
|
|
35
|
+
// import cannot be made to fail and succeed within one file.
|
|
36
|
+
vi.mock("@paystack/inline-js", () => {
|
|
37
|
+
return {
|
|
38
|
+
default: class FakePaystackPop {
|
|
39
|
+
resumeTransaction(accessCode: string, options: ResumeOptions = {}) {
|
|
40
|
+
resumeCalls.push({ accessCode });
|
|
41
|
+
resumeBehaviour(accessCode, options);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
cancelTransaction() {}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Imported after the mock is registered (vitest hoists `vi.mock`, so a plain
|
|
50
|
+
// top-level import would be fine; this is only for readability).
|
|
51
|
+
import { openPaystackCheckout, paystackReturnUrl, hasPaystackSession } from "../paystackCheckout";
|
|
52
|
+
|
|
53
|
+
const HOSTED = "https://checkout.paystack.com/abc123";
|
|
54
|
+
const RETURN = "https://artist.test/checkout/finalise?orderId=order-1";
|
|
55
|
+
|
|
56
|
+
/** jsdom refuses real navigation, so `location` is replaced with a plain bag. */
|
|
57
|
+
let location: { href: string };
|
|
58
|
+
|
|
59
|
+
const addPaystackIframe = () => {
|
|
60
|
+
const iframe = document.createElement("iframe");
|
|
61
|
+
iframe.setAttribute("src", "https://checkout.paystack.com/frame");
|
|
62
|
+
document.body.appendChild(iframe);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.useFakeTimers();
|
|
67
|
+
resumeCalls.length = 0;
|
|
68
|
+
resumeBehaviour = () => {};
|
|
69
|
+
document.body.innerHTML = "";
|
|
70
|
+
location = { href: "https://artist.test/checkout" };
|
|
71
|
+
Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.useRealTimers();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("paystackReturnUrl", () => {
|
|
79
|
+
it("appends the reference the hosted redirect would have carried", () => {
|
|
80
|
+
const url = paystackReturnUrl(RETURN, { reference: "pay-uuid" });
|
|
81
|
+
expect(url).toContain("orderId=order-1");
|
|
82
|
+
expect(url).toContain("reference=pay-uuid");
|
|
83
|
+
expect(url).toContain("trxref=pay-uuid");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("falls back to trxref when only that is reported", () => {
|
|
87
|
+
expect(paystackReturnUrl(RETURN, { trxref: "pay-uuid" })).toContain("reference=pay-uuid");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns the URL untouched when no reference came back", () => {
|
|
91
|
+
expect(paystackReturnUrl(RETURN, {})).toBe(RETURN);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("hasPaystackSession", () => {
|
|
96
|
+
it("is true for an access code, true for a hosted URL alone, false for neither", () => {
|
|
97
|
+
expect(hasPaystackSession({ accessCode: "ac" })).toBe(true);
|
|
98
|
+
expect(hasPaystackSession({ checkoutUrl: HOSTED })).toBe(true);
|
|
99
|
+
expect(hasPaystackSession({})).toBe(false);
|
|
100
|
+
expect(hasPaystackSession(null)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("openPaystackCheckout", () => {
|
|
105
|
+
it("opens the modal and, on success, continues down the redirect's own return leg", async () => {
|
|
106
|
+
resumeBehaviour = (_code, options) => {
|
|
107
|
+
options.onLoad?.({});
|
|
108
|
+
options.onSuccess?.({ reference: "pay-uuid" });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
112
|
+
|
|
113
|
+
expect(outcome).toBe("succeeded");
|
|
114
|
+
expect(resumeCalls).toEqual([{ accessCode: "ac_1" }]);
|
|
115
|
+
// The fan stayed on the creator's site right up until the finalise page.
|
|
116
|
+
expect(location.href).toContain("/checkout/finalise");
|
|
117
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("hands success to the caller instead of navigating when it finalizes in place", async () => {
|
|
121
|
+
const onSuccess = vi.fn();
|
|
122
|
+
resumeBehaviour = (_code, options) => options.onSuccess?.({ reference: "pay-uuid" });
|
|
123
|
+
|
|
124
|
+
const outcome = await openPaystackCheckout(
|
|
125
|
+
{ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN },
|
|
126
|
+
{ onSuccess },
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(outcome).toBe("succeeded");
|
|
130
|
+
expect(onSuccess).toHaveBeenCalledWith({ reference: "pay-uuid" });
|
|
131
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("treats a dismissal as retryable: no navigation, no error, and the same code opens again", async () => {
|
|
135
|
+
const onError = vi.fn();
|
|
136
|
+
const onDismiss = vi.fn();
|
|
137
|
+
resumeBehaviour = (_code, options) => {
|
|
138
|
+
options.onLoad?.({});
|
|
139
|
+
options.onCancel?.();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const first = await openPaystackCheckout(
|
|
143
|
+
{ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN },
|
|
144
|
+
{ onDismiss, onError },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(first).toBe("dismissed");
|
|
148
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
149
|
+
// A closed popup is not a failure: nothing is reported and nothing moves.
|
|
150
|
+
expect(onError).not.toHaveBeenCalled();
|
|
151
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
152
|
+
|
|
153
|
+
// ... and the very same session opens again on the next click.
|
|
154
|
+
resumeBehaviour = (_code, options) => options.onSuccess?.({ reference: "pay-uuid" });
|
|
155
|
+
const second = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
156
|
+
expect(second).toBe("succeeded");
|
|
157
|
+
expect(resumeCalls).toEqual([{ accessCode: "ac_1" }, { accessCode: "ac_1" }]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("reports a genuine provider failure as failed, without redirecting", async () => {
|
|
161
|
+
const onError = vi.fn();
|
|
162
|
+
resumeBehaviour = (_code, options) => {
|
|
163
|
+
options.onLoad?.({});
|
|
164
|
+
options.onError?.({ message: "Card declined" });
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED }, { onError });
|
|
168
|
+
|
|
169
|
+
expect(outcome).toBe("failed");
|
|
170
|
+
expect(onError).toHaveBeenCalledWith("Card declined");
|
|
171
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls back to the hosted page when resumeTransaction throws", async () => {
|
|
175
|
+
resumeBehaviour = () => {
|
|
176
|
+
throw new Error("popup blew up");
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const outcome = await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
180
|
+
|
|
181
|
+
expect(outcome).toBe("redirected");
|
|
182
|
+
expect(location.href).toBe(HOSTED);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("falls back to the hosted page when the modal never appears", async () => {
|
|
186
|
+
// No onLoad, no iframe: exactly what a silently-blocked popup looks like.
|
|
187
|
+
resumeBehaviour = () => {};
|
|
188
|
+
|
|
189
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
190
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
191
|
+
|
|
192
|
+
expect(await pending).toBe("redirected");
|
|
193
|
+
expect(location.href).toBe(HOSTED);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does NOT redirect when the modal is up but the fan is still deciding", async () => {
|
|
197
|
+
let captured: ResumeOptions = {};
|
|
198
|
+
resumeBehaviour = (_code, options) => {
|
|
199
|
+
captured = options;
|
|
200
|
+
addPaystackIframe();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
204
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
205
|
+
// The watchdog has long since fired; the iframe is what stops it acting.
|
|
206
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
207
|
+
|
|
208
|
+
captured.onSuccess?.({ reference: "pay-uuid" });
|
|
209
|
+
expect(await pending).toBe("succeeded");
|
|
210
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The two ways the modal-present check used to say yes when the fan was
|
|
215
|
+
* looking at nothing. Both suppress the hosted-page fallback, which is the
|
|
216
|
+
* only thing standing between that fan and a dead button.
|
|
217
|
+
*/
|
|
218
|
+
it("still falls back when a DISMISSED modal's iframe is hidden rather than removed", async () => {
|
|
219
|
+
const iframe = document.createElement("iframe");
|
|
220
|
+
iframe.setAttribute("src", "https://checkout.paystack.com/frame");
|
|
221
|
+
iframe.style.display = "none";
|
|
222
|
+
document.body.appendChild(iframe);
|
|
223
|
+
|
|
224
|
+
resumeBehaviour = () => {};
|
|
225
|
+
|
|
226
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
227
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
228
|
+
|
|
229
|
+
expect(await pending).toBe("redirected");
|
|
230
|
+
expect(location.href).toBe(HOSTED);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("still falls back when an unrelated iframe merely mentions paystack in its src", async () => {
|
|
234
|
+
// A third-party frame echoing the current page URL back in its own src is
|
|
235
|
+
// enough to match `iframe[src*="paystack"]` on any paystack-ish host.
|
|
236
|
+
const iframe = document.createElement("iframe");
|
|
237
|
+
iframe.setAttribute("src", "https://metrics.example.com/p?ref=https://paystack.artist.test/checkout");
|
|
238
|
+
document.body.appendChild(iframe);
|
|
239
|
+
|
|
240
|
+
resumeBehaviour = () => {};
|
|
241
|
+
|
|
242
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
243
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
244
|
+
|
|
245
|
+
expect(await pending).toBe("redirected");
|
|
246
|
+
expect(location.href).toBe(HOSTED);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("goes straight to the hosted page when there is no access code to resume", async () => {
|
|
250
|
+
const outcome = await openPaystackCheckout({ checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
251
|
+
|
|
252
|
+
expect(outcome).toBe("redirected");
|
|
253
|
+
expect(location.href).toBe(HOSTED);
|
|
254
|
+
expect(resumeCalls).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("reports unavailable, and never navigates, when there is no session at all", async () => {
|
|
258
|
+
const onError = vi.fn();
|
|
259
|
+
|
|
260
|
+
const outcome = await openPaystackCheckout({}, { onError });
|
|
261
|
+
|
|
262
|
+
expect(outcome).toBe("unavailable");
|
|
263
|
+
expect(onError).toHaveBeenCalled();
|
|
264
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The blocked-script case, in its own file because a `vi.mock` factory is
|
|
6
|
+
* evaluated once per module registry: the import cannot both fail and succeed
|
|
7
|
+
* inside one spec file.
|
|
8
|
+
*
|
|
9
|
+
* This is the headline reason the Paystack bundle is loaded lazily rather than
|
|
10
|
+
* imported at the top of the module: a CSP, an ad blocker, an offline chunk or
|
|
11
|
+
* a corporate proxy takes the popup away, and the fan must still be able to pay
|
|
12
|
+
* on the hosted page instead of the whole page failing to render.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
vi.mock("@paystack/inline-js", () => {
|
|
16
|
+
throw new Error("chunk load failed");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
import { openPaystackCheckout } from "../paystackCheckout";
|
|
20
|
+
|
|
21
|
+
const HOSTED = "https://checkout.paystack.com/abc123";
|
|
22
|
+
|
|
23
|
+
let location: { href: string };
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
location = { href: "https://artist.test/checkout" };
|
|
27
|
+
Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("openPaystackCheckout when the inline script cannot load", () => {
|
|
31
|
+
it("sends the fan to the hosted page rather than dead-ending them", async () => {
|
|
32
|
+
const outcome = await openPaystackCheckout({
|
|
33
|
+
accessCode: "ac_1",
|
|
34
|
+
checkoutUrl: HOSTED,
|
|
35
|
+
returnUrl: "https://artist.test/checkout/finalise?orderId=order-1",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(outcome).toBe("redirected");
|
|
39
|
+
expect(location.href).toBe(HOSTED);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reports the failure instead of navigating when there is no hosted URL to fall back to", async () => {
|
|
43
|
+
const onError = vi.fn();
|
|
44
|
+
|
|
45
|
+
const outcome = await openPaystackCheckout({ accessCode: "ac_1" }, { onError });
|
|
46
|
+
|
|
47
|
+
expect(outcome).toBe("unavailable");
|
|
48
|
+
expect(onError).toHaveBeenCalled();
|
|
49
|
+
expect(location.href).toBe("https://artist.test/checkout");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -161,8 +161,8 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
|
|
|
161
161
|
return {
|
|
162
162
|
label: "Payment failed",
|
|
163
163
|
detail: until
|
|
164
|
-
? `We couldn't charge your payment method. You still have full access until ${until}
|
|
165
|
-
: "We couldn't charge your payment method. You still have full access
|
|
164
|
+
? `We couldn't charge your payment method. You still have full access until ${until}. Update your card to keep it.`
|
|
165
|
+
: "We couldn't charge your payment method. You still have full access. Update your card to keep it.",
|
|
166
166
|
showUpdatePayment: true,
|
|
167
167
|
tone: "warning",
|
|
168
168
|
};
|
|
@@ -171,7 +171,7 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
|
|
|
171
171
|
case MEMBERSHIP_BILLING_STATE.pending:
|
|
172
172
|
return {
|
|
173
173
|
label: "Pending",
|
|
174
|
-
detail: "Your membership isn't active yet
|
|
174
|
+
detail: "Your membership isn't active yet. Finish checkout to unlock it.",
|
|
175
175
|
showUpdatePayment: false,
|
|
176
176
|
tone: "neutral",
|
|
177
177
|
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/// <reference path="../types/paystack-inline.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paystack inline checkout: the ONE place in Forge that knows the popup exists.
|
|
5
|
+
*
|
|
6
|
+
* The fan pays in a modal on the creator's own site instead of being sent to a
|
|
7
|
+
* Paystack-branded page and back. Every pillar (cart, tickets, courses,
|
|
8
|
+
* coaching, invoices, payment links, donations, offers, memberships) reaches
|
|
9
|
+
* Paystack through this function, so there is exactly one implementation of the
|
|
10
|
+
* lazy load, the fallback and the success navigation to reason about.
|
|
11
|
+
*
|
|
12
|
+
* Four properties are load-bearing:
|
|
13
|
+
*
|
|
14
|
+
* 1. **SSR safety.** Forge sites are TanStack Start, so this module is evaluated
|
|
15
|
+
* on the server. Nothing here touches `window`, `document` or the Paystack
|
|
16
|
+
* bundle at module scope; the browser check happens before any of it.
|
|
17
|
+
* 2. **Lazy load.** `@paystack/inline-js` is reached through a dynamic
|
|
18
|
+
* `import()`, so it never enters the initial bundle and a blocked script
|
|
19
|
+
* cannot break page render: it becomes a rejected promise we can handle.
|
|
20
|
+
* 3. **One runtime fallback, not a version shim.** When the modal cannot open
|
|
21
|
+
* (import rejected, `resumeTransaction` threw, or nothing appeared within
|
|
22
|
+
* `MODAL_OPEN_TIMEOUT_MS`), the fan goes to the hosted page carried on
|
|
23
|
+
* `checkoutUrl`. The sale survives a blocked CDN, a CSP, or an extension.
|
|
24
|
+
* This is about browser conditions, never about which Forge version a site
|
|
25
|
+
* compiled against.
|
|
26
|
+
* 4. **Dismissal is not failure.** Closing the modal without paying resolves
|
|
27
|
+
* `"dismissed"`. Nothing is marked failed and the caller can open it again.
|
|
28
|
+
*
|
|
29
|
+
* Stripe is untouched by any of this: it keeps reading `paymentSecret` as the
|
|
30
|
+
* PaymentIntent client secret, exactly as live sites already do.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** How a checkout attempt ended. */
|
|
34
|
+
export type PaystackCheckoutOutcome =
|
|
35
|
+
/** Paid in the modal. */
|
|
36
|
+
| "succeeded"
|
|
37
|
+
/** The fan closed the modal without paying. Retryable, not a failure. */
|
|
38
|
+
| "dismissed"
|
|
39
|
+
/** Paystack reported a genuine failure after the modal opened. */
|
|
40
|
+
| "failed"
|
|
41
|
+
/** The modal could not be used, so the fan was sent to the hosted page. */
|
|
42
|
+
| "redirected"
|
|
43
|
+
/** Nothing could be done here at all (server render, or no session to open). */
|
|
44
|
+
| "unavailable";
|
|
45
|
+
|
|
46
|
+
/** The bits of a completed Paystack transaction the return leg needs. */
|
|
47
|
+
export type PaystackTransactionResult = {
|
|
48
|
+
reference?: string;
|
|
49
|
+
trxref?: string;
|
|
50
|
+
transaction?: string;
|
|
51
|
+
status?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A started Paystack charge, exactly as `start-payment` reports it.
|
|
56
|
+
*
|
|
57
|
+
* `accessCode` and `checkoutUrl` arrive in the SAME `/transaction/initialize`
|
|
58
|
+
* response, which is why carrying the fallback costs one field rather than a
|
|
59
|
+
* second round trip.
|
|
60
|
+
*/
|
|
61
|
+
export type PaystackCheckoutSession = {
|
|
62
|
+
/** Checkout-session access code. Carries the session; no public key needed. */
|
|
63
|
+
accessCode?: string | null;
|
|
64
|
+
/** Hosted checkout URL. The runtime fallback. */
|
|
65
|
+
checkoutUrl?: string | null;
|
|
66
|
+
/** Where the redirect leg would have landed. Used on success by default. */
|
|
67
|
+
returnUrl?: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type PaystackCheckoutHandlers = {
|
|
71
|
+
/**
|
|
72
|
+
* Paid. When omitted, the helper navigates to `returnUrl` with Paystack's own
|
|
73
|
+
* `reference`/`trxref` appended, byte for byte what the hosted redirect did,
|
|
74
|
+
* so every finalise page keeps working untouched. Supply this only when the
|
|
75
|
+
* surface finalises in place (a modal that shows its own receipt).
|
|
76
|
+
*/
|
|
77
|
+
onSuccess?: (result: PaystackTransactionResult) => void;
|
|
78
|
+
/** The fan closed the modal. Re-enable the pay button; change nothing else. */
|
|
79
|
+
onDismiss?: () => void;
|
|
80
|
+
/** A real failure, or no usable session at all. */
|
|
81
|
+
onError?: (message?: string) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How long to wait for the modal to show itself before deciding it will not.
|
|
86
|
+
*
|
|
87
|
+
* Long enough that a slow phone on a bad connection is not thrown out of a
|
|
88
|
+
* working modal, short enough that a fan staring at a dead button gives up
|
|
89
|
+
* before we do.
|
|
90
|
+
*/
|
|
91
|
+
const MODAL_OPEN_TIMEOUT_MS = 4000;
|
|
92
|
+
const MODAL_POLL_MS = 200;
|
|
93
|
+
|
|
94
|
+
const inBrowser = (): boolean => typeof window !== "undefined" && typeof document !== "undefined";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is Paystack's checkout iframe actually on screen right now?
|
|
98
|
+
*
|
|
99
|
+
* This decides whether the 4s watchdog falls back to the hosted page, so a
|
|
100
|
+
* false positive strands a fan on a dead button. The obvious selector,
|
|
101
|
+
* `iframe[src*="paystack"]`, produces one in two ways that both happen in
|
|
102
|
+
* practice:
|
|
103
|
+
*
|
|
104
|
+
* 1. A DISMISSED modal's iframe is hidden, not removed. On a second attempt it
|
|
105
|
+
* still matches, so the watchdog concludes the modal opened when nothing
|
|
106
|
+
* did. Hence the visibility check rather than mere presence.
|
|
107
|
+
* 2. Any iframe whose src merely CONTAINS the string, including third-party
|
|
108
|
+
* frames that echo the current page URL back in their own src, which match
|
|
109
|
+
* on any host with "paystack" in it. Hence matching the parsed HOST rather
|
|
110
|
+
* than a substring of the whole URL.
|
|
111
|
+
*/
|
|
112
|
+
const modalIsPresent = (): boolean => {
|
|
113
|
+
const frames = Array.from(document.querySelectorAll<HTMLIFrameElement>("iframe[src]"));
|
|
114
|
+
return frames.some((frame) => {
|
|
115
|
+
let host: string;
|
|
116
|
+
try {
|
|
117
|
+
host = new URL(frame.src, window.location.href).hostname.toLowerCase();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (host !== "paystack.com" && !host.endsWith(".paystack.com")) return false;
|
|
122
|
+
|
|
123
|
+
// Visibility, not layout size. `getBoundingClientRect` would be the more
|
|
124
|
+
// thorough test, but the checks that matter are the ones Paystack actually
|
|
125
|
+
// uses to put a dismissed modal away, and this keeps the rule assertable.
|
|
126
|
+
if (frame.hidden) return false;
|
|
127
|
+
const style = window.getComputedStyle(frame);
|
|
128
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const errorMessageOf = (error: unknown): string | undefined => {
|
|
133
|
+
if (typeof error === "string") return error;
|
|
134
|
+
const message = (error as { message?: unknown } | null)?.message;
|
|
135
|
+
return typeof message === "string" ? message : undefined;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The URL the hosted redirect would have landed on.
|
|
140
|
+
*
|
|
141
|
+
* Paystack appends `reference` and `trxref` to the callback URL, and finalise
|
|
142
|
+
* pages have always been able to read them. Appending the same two params after
|
|
143
|
+
* a modal success is what makes "nothing downstream changes" literally true.
|
|
144
|
+
*/
|
|
145
|
+
export function paystackReturnUrl(returnUrl: string, result: PaystackTransactionResult): string {
|
|
146
|
+
const reference = result.reference || result.trxref;
|
|
147
|
+
if (!reference) return returnUrl;
|
|
148
|
+
try {
|
|
149
|
+
const base = inBrowser() ? window.location.href : undefined;
|
|
150
|
+
const url = new URL(returnUrl, base);
|
|
151
|
+
url.searchParams.set("reference", reference);
|
|
152
|
+
url.searchParams.set("trxref", reference);
|
|
153
|
+
return url.toString();
|
|
154
|
+
} catch {
|
|
155
|
+
// A relative URL with no base to resolve against. The caller's own return
|
|
156
|
+
// path is still the right destination; it just carries no reference.
|
|
157
|
+
return returnUrl;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when a start-payment result is a Paystack session this helper can act on. */
|
|
162
|
+
export function hasPaystackSession(session: PaystackCheckoutSession | null | undefined): boolean {
|
|
163
|
+
return !!(session && (session.accessCode || session.checkoutUrl));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Open the Paystack modal for a started charge.
|
|
168
|
+
*
|
|
169
|
+
* Resolves once the attempt has ended one way or another; it never rejects, so
|
|
170
|
+
* a caller can `await` it from a click handler without a try/catch.
|
|
171
|
+
*/
|
|
172
|
+
export async function openPaystackCheckout(
|
|
173
|
+
session: PaystackCheckoutSession,
|
|
174
|
+
handlers: PaystackCheckoutHandlers = {},
|
|
175
|
+
): Promise<PaystackCheckoutOutcome> {
|
|
176
|
+
// Server render: do nothing at all, and say so rather than throwing.
|
|
177
|
+
if (!inBrowser()) return "unavailable";
|
|
178
|
+
|
|
179
|
+
const accessCode = session.accessCode || undefined;
|
|
180
|
+
const checkoutUrl = session.checkoutUrl || undefined;
|
|
181
|
+
|
|
182
|
+
if (!accessCode && !checkoutUrl) {
|
|
183
|
+
handlers.onError?.("This payment could not be started. Please try again.");
|
|
184
|
+
return "unavailable";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The one runtime fallback: the hosted page the same initialize call returned. */
|
|
188
|
+
const goToHostedPage = (): PaystackCheckoutOutcome => {
|
|
189
|
+
if (!checkoutUrl) {
|
|
190
|
+
handlers.onError?.("The payment window could not be opened. Please try again.");
|
|
191
|
+
return "unavailable";
|
|
192
|
+
}
|
|
193
|
+
window.location.href = checkoutUrl;
|
|
194
|
+
return "redirected";
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!accessCode) return goToHostedPage();
|
|
198
|
+
|
|
199
|
+
let PaystackPop: typeof import("@paystack/inline-js").default;
|
|
200
|
+
try {
|
|
201
|
+
// Lazily imported so a blocked CDN, an offline chunk or a CSP is a rejected
|
|
202
|
+
// promise here instead of a page that will not render.
|
|
203
|
+
PaystackPop = (await import("@paystack/inline-js")).default;
|
|
204
|
+
} catch {
|
|
205
|
+
return goToHostedPage();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new Promise<PaystackCheckoutOutcome>((resolve) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
let opened = false;
|
|
211
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
212
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
|
|
214
|
+
const stopWatching = () => {
|
|
215
|
+
if (poll) clearInterval(poll);
|
|
216
|
+
if (watchdog) clearTimeout(watchdog);
|
|
217
|
+
poll = undefined;
|
|
218
|
+
watchdog = undefined;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const settle = (outcome: PaystackCheckoutOutcome) => {
|
|
222
|
+
if (settled) return false;
|
|
223
|
+
settled = true;
|
|
224
|
+
stopWatching();
|
|
225
|
+
resolve(outcome);
|
|
226
|
+
return true;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const succeed = (result: PaystackTransactionResult) => {
|
|
230
|
+
if (!settle("succeeded")) return;
|
|
231
|
+
if (handlers.onSuccess) {
|
|
232
|
+
handlers.onSuccess(result);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// No handler: continue down the exact path the redirect used.
|
|
236
|
+
if (session.returnUrl) window.location.href = paystackReturnUrl(session.returnUrl, result);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
new PaystackPop().resumeTransaction(accessCode, {
|
|
241
|
+
onSuccess: (transaction) => succeed(transaction ?? {}),
|
|
242
|
+
// Closing the popup leaves the charge exactly where it was. Nothing is
|
|
243
|
+
// failed, nothing is cancelled server-side, and the same access code
|
|
244
|
+
// still opens the same session on the next click.
|
|
245
|
+
onCancel: () => {
|
|
246
|
+
if (settle("dismissed")) handlers.onDismiss?.();
|
|
247
|
+
},
|
|
248
|
+
// The iframe is up, so the watchdog has nothing left to rescue.
|
|
249
|
+
onLoad: () => {
|
|
250
|
+
opened = true;
|
|
251
|
+
stopWatching();
|
|
252
|
+
},
|
|
253
|
+
onError: (error) => {
|
|
254
|
+
if (settle("failed")) handlers.onError?.(errorMessageOf(error));
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
// `resumeTransaction` threw synchronously, so the modal never got started.
|
|
259
|
+
settle(goToHostedPage());
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
poll = setInterval(() => {
|
|
264
|
+
if (!modalIsPresent()) return;
|
|
265
|
+
opened = true;
|
|
266
|
+
stopWatching();
|
|
267
|
+
}, MODAL_POLL_MS);
|
|
268
|
+
|
|
269
|
+
watchdog = setTimeout(() => {
|
|
270
|
+
stopWatching();
|
|
271
|
+
if (settled || opened) return;
|
|
272
|
+
// The modal never appeared. Send the fan to the hosted page rather than
|
|
273
|
+
// leaving them looking at a button that did nothing.
|
|
274
|
+
settle(goToHostedPage());
|
|
275
|
+
}, MODAL_OPEN_TIMEOUT_MS);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -118,7 +118,7 @@ export const TICKET_ORDER_REFUNDED_COPY = {
|
|
|
118
118
|
eyebrow: "Refunded",
|
|
119
119
|
heading: "This order was refunded",
|
|
120
120
|
body:
|
|
121
|
-
"Your payment has been sent back to the original payment method
|
|
121
|
+
"Your payment has been sent back to the original payment method. How long it takes to appear is up to your bank or card issuer. " +
|
|
122
122
|
"These tickets have been cancelled and will not admit entry.",
|
|
123
123
|
/** Replaces "Total paid" on the amount line. */
|
|
124
124
|
totalLabel: "Total refunded",
|