@tribe-nest/forge 3.21.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.
Files changed (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
4
+ import { AddToCalendar } from "../AddToCalendar";
5
+
6
+ /**
7
+ * Rendered through `react-dom/server`, which needs no DOM — same approach as
8
+ * `EventConfirmation.spec.tsx`.
9
+ *
10
+ * What this guards is the anchor ATTRIBUTES, because they are what actually
11
+ * decides whether an iPhone opens Calendar. A `webcal:` link with
12
+ * `target="_blank"` leaves a blank tab behind and, on several browsers, never
13
+ * reaches the OS handler at all; one with `download` asks to save a file from a
14
+ * scheme that has no file. Both are easy to add back by reflex, and neither
15
+ * failure is visible from a desktop.
16
+ */
17
+
18
+ const THEME = {
19
+ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
20
+ cornerRadius: 8,
21
+ };
22
+
23
+ const render = (node: React.ReactNode) =>
24
+ renderToStaticMarkup(<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>);
25
+
26
+ const ICS = "https://api.tribenest.co/public/calendar/events/abc/tok.ics";
27
+ const WEBCAL = "webcal://api.tribenest.co/public/calendar/events/abc/tok.ics";
28
+
29
+ const base = { title: "Night Show", start: "2026-03-15T20:00:00.000Z" };
30
+
31
+ describe("<AddToCalendar>", () => {
32
+ it("draws only the three template providers when no .ics is supplied", () => {
33
+ const html = render(<AddToCalendar {...base} />);
34
+
35
+ expect(html).toContain("add-to-calendar-google");
36
+ expect(html).toContain("add-to-calendar-outlook");
37
+ expect(html).toContain("add-to-calendar-office365");
38
+ // The pre-existing behaviour, preserved for a site on an older API build:
39
+ // no Apple chip rather than a dead one.
40
+ expect(html).not.toContain("add-to-calendar-apple");
41
+ expect(html).not.toContain("add-to-calendar-ics");
42
+ });
43
+
44
+ it("adds Apple Calendar and a download once the host has the address", () => {
45
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
46
+
47
+ expect(html).toContain("add-to-calendar-apple");
48
+ expect(html).toContain(`href="${WEBCAL}"`);
49
+ expect(html).toContain("Apple Calendar");
50
+
51
+ expect(html).toContain("add-to-calendar-ics");
52
+ expect(html).toContain("Download .ics");
53
+ });
54
+
55
+ it("the Apple anchor is a plain same-window navigation", () => {
56
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
57
+
58
+ // Isolate the Apple anchor so the assertions cannot be satisfied by the
59
+ // Google one sitting next to it.
60
+ const apple = html.slice(html.indexOf('data-testid="add-to-calendar-apple"'));
61
+ const anchor = apple.slice(0, apple.indexOf(">"));
62
+
63
+ expect(anchor).not.toContain('target="_blank"');
64
+ expect(anchor).not.toContain("download");
65
+ });
66
+
67
+ it("the .ics anchor DOES ask to download", () => {
68
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
69
+ const ics = html.slice(html.indexOf('data-testid="add-to-calendar-ics"'));
70
+ expect(ics.slice(0, ics.indexOf(">"))).toContain("download");
71
+ });
72
+
73
+ it("still offers Apple when only the https form is passed", () => {
74
+ // The webcal address is derived; a host that only forwards `calendarUrl`
75
+ // must not lose the one option iOS can use.
76
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} />);
77
+ expect(html).toContain(`href="${WEBCAL}"`);
78
+ });
79
+
80
+ it("renders nothing at all for an unusable start time", () => {
81
+ // The theme provider still emits its `:root` variables, so the assertion is
82
+ // that no ANCHOR was drawn — a chip pointing at a garbage prefill is worse
83
+ // than no chip.
84
+ const html = render(<AddToCalendar title="X" start="not-a-date" icsUrl={ICS} webcalUrl={WEBCAL} />);
85
+ expect(html).not.toContain("<a ");
86
+ expect(html).not.toContain("add-to-calendar");
87
+ });
88
+ });
@@ -38,7 +38,11 @@ const baseOrder: ITicketOrder = {
38
38
  function render(order: ITicketOrder) {
39
39
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
40
40
  queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
41
- queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
41
+ // Four-element key: `useEvent` gained a presale `accessCode` segment, which
42
+ // is `null` for an ordinary read. Seeding the old three-element key left
43
+ // `event` undefined, which silently dropped the add-to-calendar block — the
44
+ // assertion below is what caught it.
45
+ queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID, null], {
42
46
  id: EVENT_ID,
43
47
  title: "Midnight Set",
44
48
  description: "A set",
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
4
+ import { PresaleCodeField } from "../PresaleCode";
5
+ import type { PresaleCodeField as PresaleCodeState } from "../../headless/event/usePresaleCode";
6
+
7
+ /**
8
+ * Events 1.2 — the box a buyer types a presale code into.
9
+ *
10
+ * Rendered through `react-dom/server`, which needs no DOM, so these run in the
11
+ * package's existing node vitest environment. Effects never fire under
12
+ * `renderToStaticMarkup`, so the state fixture is the only data source and
13
+ * nothing reaches the network.
14
+ *
15
+ * This is the REAL component the Forge storefront renders, and the client app's
16
+ * own copy is a presentational twin driven by the same `usePresaleCode` state —
17
+ * so what is asserted here about WHAT IS SAID holds on both rendering stacks.
18
+ */
19
+
20
+ function state(overrides: Partial<PresaleCodeState> = {}): PresaleCodeState {
21
+ return {
22
+ event: undefined,
23
+ isLoading: false,
24
+ visible: true,
25
+ input: "",
26
+ setInput: () => {},
27
+ code: null,
28
+ status: "idle",
29
+ message: null,
30
+ submit: () => {},
31
+ clear: () => {},
32
+ isUnlocked: false,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
38
+
39
+ const render = (presale: PresaleCodeState) =>
40
+ renderToStaticMarkup(
41
+ <ForgeThemeProvider theme={theme}>
42
+ <PresaleCodeField presale={presale} />
43
+ </ForgeThemeProvider>,
44
+ );
45
+
46
+ describe("PresaleCodeField", () => {
47
+ it("REGRESSION: renders NOTHING on an event with no coded tier", () => {
48
+ // The whole reason `hasCodedTiers` is a server fact. A code box on an
49
+ // ordinary event tells a buyer there is a secret they are missing and gives
50
+ // them no way to discover there isn't one.
51
+ const html = render(state({ visible: false }));
52
+ // The provider still emits its :root variables; nothing of the FIELD is there.
53
+ expect(html).not.toContain("presale-code");
54
+ expect(html).not.toContain("presale code");
55
+ });
56
+
57
+ it("offers the prompt and an input when a presale exists", () => {
58
+ const html = render(state());
59
+ expect(html).toContain("Have a presale code?");
60
+ expect(html).toContain('data-testid="presale-code-input"');
61
+ expect(html).toContain("Unlock");
62
+ });
63
+
64
+ it("disables Unlock until something is typed", () => {
65
+ expect(render(state())).toContain("disabled");
66
+ expect(render(state({ input: "PRESALE24" }))).not.toContain("disabled");
67
+ });
68
+
69
+ it("says the code is not valid for the EVENT, naming no tier", () => {
70
+ const html = render(
71
+ state({ code: "GUESS", status: "rejected", message: "That code isn't valid for this event." }),
72
+ );
73
+ expect(html).toContain("That code isn&#x27;t valid for this event.");
74
+ // A refusal that named a tier — "that isn't the VIP code" — would turn the
75
+ // box into a way to enumerate them.
76
+ expect(html.toLowerCase()).not.toContain("tier");
77
+ expect(html).toContain('role="alert"');
78
+ expect(html).toContain('aria-invalid="true"');
79
+ });
80
+
81
+ it("confirms an accepted code, with a way to remove it", () => {
82
+ // Without this, a code for a tier that was already VISIBLE (code-gated but
83
+ // listed) appears to do nothing at all: the tier list is unchanged.
84
+ const html = render(state({ code: "PRESALE24", status: "accepted", isUnlocked: true, message: "Code applied." }));
85
+ expect(html).toContain("Presale unlocked: PRESALE24");
86
+ expect(html).toContain('data-testid="presale-code-remove"');
87
+ // The input is gone — there is nothing left to type.
88
+ expect(html).not.toContain('data-testid="presale-code-input"');
89
+ });
90
+
91
+ it("shows a pending state while the server decides", () => {
92
+ const html = render(state({ code: "PRESALE24", input: "PRESALE24", status: "checking" }));
93
+ expect(html).toContain("Checking…");
94
+ // No verdict yet, so no message either way.
95
+ expect(html).not.toContain('data-testid="presale-code-message"');
96
+ });
97
+
98
+ it("takes its colours from the theme, not a hardcoded palette", () => {
99
+ const html = renderToStaticMarkup(
100
+ <ForgeThemeProvider theme={{ ...theme, colors: { ...theme.colors, primary: "#0f766e" } }}>
101
+ <PresaleCodeField presale={state({ code: "X", status: "accepted", isUnlocked: true })} />
102
+ </ForgeThemeProvider>,
103
+ );
104
+ expect(html).toContain("#0f766e");
105
+ });
106
+ });
@@ -329,7 +329,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
329
329
  </div>
330
330
  <div style={{ fontSize: 13, color: t.muted, maxWidth: 300 }}>
331
331
  {isForum
332
- ? "Kick things off start the first discussion in this space."
332
+ ? "Kick things off: start the first discussion in this space."
333
333
  : "Be the first to share something with the community."}
334
334
  </div>
335
335
  <button
@@ -237,7 +237,7 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
237
237
  {locked ? (
238
238
  <div style={{ border, borderRadius: t.cornerRadius, padding: 14, textAlign: "center", color: t.muted, fontSize: 14 }}>
239
239
  <Lock size={13} style={{ verticalAlign: -2, marginRight: 6 }} />
240
- This post is locked replies are closed.
240
+ This post is locked: replies are closed.
241
241
  </div>
242
242
  ) : (
243
243
  !replyingTo && <CommunityComposer compact placeholder="Write a reply…" submitLabel="Reply" onSubmit={submitReply} />
@@ -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
+ });