@tribe-nest/forge 3.34.0 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +130 -28
- package/src/i18n/en.json +130 -28
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +21 -16
- package/src/ui/media/CallStage.tsx +59 -1
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +101 -37
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +291 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/forge-utilities.css +6 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -27,6 +27,7 @@ import { AccountDashboard } from "../AccountDashboard";
|
|
|
27
27
|
const PROFILE_ID = "profile-1";
|
|
28
28
|
const ACCOUNT_ID = "account-1";
|
|
29
29
|
const MINUTE = 60_000;
|
|
30
|
+
const HOUR = 60 * MINUTE;
|
|
30
31
|
|
|
31
32
|
const member = {
|
|
32
33
|
id: ACCOUNT_ID,
|
|
@@ -59,6 +60,9 @@ const booking = (overrides: Partial<MyBooking> = {}): MyBooking => {
|
|
|
59
60
|
cancellationOutcome: null,
|
|
60
61
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
61
62
|
coachingBookingSlotId: "slot-1",
|
|
63
|
+
// The call's address, written when the booking was confirmed. A real
|
|
64
|
+
// confirmed video booking always carries one.
|
|
65
|
+
videoCallId: "call-1",
|
|
62
66
|
sessionStartTime: start.toISOString(),
|
|
63
67
|
sessionEndTime: end.toISOString(),
|
|
64
68
|
coachingProductId: "product-1",
|
|
@@ -117,10 +121,34 @@ describe("AccountDashboard: the way into a coaching video call", () => {
|
|
|
117
121
|
|
|
118
122
|
expect(html).toContain("Video session");
|
|
119
123
|
expect(html).toContain("Join video call");
|
|
124
|
+
// A LINK to the call's own page, at the same address the coach opens from
|
|
125
|
+
// the dashboard. It used to mount the room inline, between two rows of a
|
|
126
|
+
// list, which is not what a call looks like anywhere else.
|
|
127
|
+
expect(html).toContain('href="/i/video-calls/call-1"');
|
|
120
128
|
// The line still says where the session is; the control is in addition.
|
|
121
129
|
expect(html).toContain("Video call on this site");
|
|
122
130
|
});
|
|
123
131
|
|
|
132
|
+
it("draws no control for a session with no call address, but still says it is a video call", () => {
|
|
133
|
+
/**
|
|
134
|
+
* Only reachable for a booking confirmed before calls became addressable,
|
|
135
|
+
* since a confirmation now writes the record. A dead button would be worse
|
|
136
|
+
* than none, and the row above still names the session as a video call.
|
|
137
|
+
*/
|
|
138
|
+
const html = render([booking({ videoCallId: null })]);
|
|
139
|
+
|
|
140
|
+
expect(html).toContain("Video call on this site");
|
|
141
|
+
expect(html).not.toContain("Join video call");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("still says when the room opens even without a call address", () => {
|
|
145
|
+
// The window copy is about the SESSION, so it must not depend on whether a
|
|
146
|
+
// room record exists. This is the branch my first attempt broke.
|
|
147
|
+
const html = render([booking({ videoCallId: null, sessionStartTime: new Date(Date.now() + 6 * HOUR).toISOString() })]);
|
|
148
|
+
|
|
149
|
+
expect(html).toMatch(/opens|Opens/);
|
|
150
|
+
});
|
|
151
|
+
|
|
124
152
|
it("says when the room opens, rather than offering a Join that would 409", () => {
|
|
125
153
|
const start = new Date(Date.now() + 3 * 60 * MINUTE);
|
|
126
154
|
const html = render([
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { MyFilmRental } from "../../../data/queries/useFilms";
|
|
8
|
+
import { AccountDashboard, type AccountTabKey } from "../AccountDashboard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rentals on the account page.
|
|
12
|
+
*
|
|
13
|
+
* A fan who rented a film could find their tickets, their orders and their
|
|
14
|
+
* coaching sessions on this page and no trace of the rental - `/i/films/library`
|
|
15
|
+
* held it, and nothing on the account page pointed there. So the tab exists, and
|
|
16
|
+
* these are the two things it must get right:
|
|
17
|
+
*
|
|
18
|
+
* LIVE - the window is open, so the row offers the way in (Resume when
|
|
19
|
+
* there is progress to resume, Watch otherwise).
|
|
20
|
+
* NOT LIVE - expired or refunded, so the row offers Rent again and NOT a
|
|
21
|
+
* Watch button that leads to a player which would refuse them.
|
|
22
|
+
*
|
|
23
|
+
* `isLive` is the SERVER's answer (`msRemaining`, `expiresAt`, `revokedAt` are
|
|
24
|
+
* all decided there). Nothing here recomputes it, and these specs assert the
|
|
25
|
+
* rendering of that flag rather than any clock arithmetic in the browser.
|
|
26
|
+
*
|
|
27
|
+
* Rendered through `react-dom/server`, like the community spec beside it: no
|
|
28
|
+
* DOM and no effects, so the rentals query is SEEDED into the react-query cache
|
|
29
|
+
* rather than mocked. That keeps the real `useMyFilmRentals` in the path, and a
|
|
30
|
+
* rename of its query key fails here.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const PROFILE_ID = "profile-1";
|
|
34
|
+
const ACCOUNT_ID = "account-1";
|
|
35
|
+
|
|
36
|
+
/** The key `useMyFilmRentals(accountId)` reads, at its default page and limit. */
|
|
37
|
+
const RENTALS_KEY = ["my-film-rentals", ACCOUNT_ID, PROFILE_ID, 1, 20];
|
|
38
|
+
|
|
39
|
+
const rental = (overrides: Partial<MyFilmRental> = {}): MyFilmRental => ({
|
|
40
|
+
id: "rental-1",
|
|
41
|
+
filmId: "film-1",
|
|
42
|
+
status: "active",
|
|
43
|
+
amountCents: 500,
|
|
44
|
+
currency: "USD",
|
|
45
|
+
rentalDurationHours: 48,
|
|
46
|
+
rentalOptionId: "option-1",
|
|
47
|
+
paidAt: "2026-08-01T00:00:00.000Z",
|
|
48
|
+
startBy: "2026-09-01T00:00:00.000Z",
|
|
49
|
+
firstPlayedAt: "2026-08-02T00:00:00.000Z",
|
|
50
|
+
expiresAt: "2026-08-04T00:00:00.000Z",
|
|
51
|
+
refundedAt: null,
|
|
52
|
+
revokedAt: null,
|
|
53
|
+
isLive: true,
|
|
54
|
+
msRemaining: 3 * 3600_000,
|
|
55
|
+
windowMs: 48 * 3600_000,
|
|
56
|
+
film: {
|
|
57
|
+
id: "film-1",
|
|
58
|
+
title: "The Long Goodbye",
|
|
59
|
+
slug: "the-long-goodbye",
|
|
60
|
+
kind: "film",
|
|
61
|
+
status: "published",
|
|
62
|
+
archivedAt: null,
|
|
63
|
+
cover: null,
|
|
64
|
+
},
|
|
65
|
+
progress: [],
|
|
66
|
+
...overrides,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const fan = {
|
|
70
|
+
id: ACCOUNT_ID,
|
|
71
|
+
email: "fan@example.com",
|
|
72
|
+
firstName: "Fan",
|
|
73
|
+
lastName: "Person",
|
|
74
|
+
kind: "fan",
|
|
75
|
+
status: "active",
|
|
76
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
77
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
78
|
+
membership: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function render(opts: { rentals: MyFilmRental[] | null; tab?: AccountTabKey }): string {
|
|
82
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
83
|
+
if (opts.rentals) {
|
|
84
|
+
queryClient.setQueryData(RENTALS_KEY, {
|
|
85
|
+
data: opts.rentals,
|
|
86
|
+
total: opts.rentals.length,
|
|
87
|
+
page: 1,
|
|
88
|
+
limit: 20,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return renderToStaticMarkup(
|
|
93
|
+
<QueryClientProvider client={queryClient}>
|
|
94
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
95
|
+
<PublicAuthContext.Provider
|
|
96
|
+
value={
|
|
97
|
+
{
|
|
98
|
+
user: fan,
|
|
99
|
+
isInitialized: true,
|
|
100
|
+
currencies: null,
|
|
101
|
+
userSelectedCurrency: "USD",
|
|
102
|
+
logout: () => {},
|
|
103
|
+
} as never
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
<ForgeThemeProvider
|
|
107
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
108
|
+
>
|
|
109
|
+
<AccountDashboard tab={opts.tab ?? "rentals"} onNavigateMembership={() => {}} />
|
|
110
|
+
</ForgeThemeProvider>
|
|
111
|
+
</PublicAuthContext.Provider>
|
|
112
|
+
</ForgeClientProvider>
|
|
113
|
+
</QueryClientProvider>,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("AccountDashboard: the fan's rentals", () => {
|
|
118
|
+
it("REGRESSION: a running rental is reachable from the account page", () => {
|
|
119
|
+
// The gap this closes. The rental existed, the player existed, and the
|
|
120
|
+
// account page mentioned neither.
|
|
121
|
+
const html = render({ rentals: [rental()] });
|
|
122
|
+
|
|
123
|
+
expect(html).toContain("Rentals");
|
|
124
|
+
expect(html).toContain("The Long Goodbye");
|
|
125
|
+
expect(html).toContain("Watch");
|
|
126
|
+
// The window is state the fan acts on, so it is on the row rather than
|
|
127
|
+
// behind a `?`.
|
|
128
|
+
expect(html).toContain("3h 0m left");
|
|
129
|
+
expect(html).not.toContain("Rent again");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("offers to resume the episode the fan stopped in the middle of", () => {
|
|
133
|
+
const html = render({
|
|
134
|
+
rentals: [rental({ progress: [{ episodeId: "episode-1", positionSec: 900, completedAt: null }] })],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(html).toContain("Resume");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("REGRESSION: an expired rental offers Rent again, never Watch", () => {
|
|
141
|
+
// Drawing Watch here walks the fan into a player that refuses them: the
|
|
142
|
+
// window is the server's to judge and it has closed.
|
|
143
|
+
const html = render({
|
|
144
|
+
rentals: [rental({ isLive: false, msRemaining: 0, expiresAt: "2026-08-04T00:00:00.000Z" })],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(html).toContain("Rent again");
|
|
148
|
+
expect(html).toContain("Your window has closed.");
|
|
149
|
+
expect(html).not.toContain(">Watch<");
|
|
150
|
+
expect(html).not.toContain(">Resume<");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("says a refunded rental was refunded rather than that it ran out", () => {
|
|
154
|
+
// Two different things happened, and "your window has closed" over a refund
|
|
155
|
+
// reads as the artist keeping the money.
|
|
156
|
+
const html = render({
|
|
157
|
+
rentals: [rental({ isLive: false, msRemaining: null, refundedAt: "2026-08-03T00:00:00.000Z" })],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(html).toContain("Refunded.");
|
|
161
|
+
expect(html).toContain("Rent again");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps a rental that has not been started apart from one that is running", () => {
|
|
165
|
+
// The clock starts at first play, so this fan has lost nothing by waiting.
|
|
166
|
+
// Saying "0m left" here would be a lie that costs them the film.
|
|
167
|
+
const html = render({ rentals: [rental({ firstPlayedAt: null, msRemaining: null, expiresAt: null })] });
|
|
168
|
+
|
|
169
|
+
expect(html).toContain("Not started.");
|
|
170
|
+
expect(html).toContain("Watch");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("does not draw the tab on a site where this fan holds no rental", () => {
|
|
174
|
+
// Film rentals are off by default and per profile. An always-empty tab on
|
|
175
|
+
// every account page is noise on every site that never sells one.
|
|
176
|
+
const html = render({ rentals: [], tab: "membership" });
|
|
177
|
+
|
|
178
|
+
expect(html).not.toContain("Rentals");
|
|
179
|
+
// The rest of the strip is untouched, so this is the one tab missing rather
|
|
180
|
+
// than the dashboard failing to draw.
|
|
181
|
+
expect(html).toContain("Tickets");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("stays quiet while the rentals query has not answered yet", () => {
|
|
185
|
+
// First paint has no cached answer. Drawing the tab on a guess flashes it
|
|
186
|
+
// and then withdraws it from fans who never rented anything.
|
|
187
|
+
const html = render({ rentals: null, tab: "membership" });
|
|
188
|
+
|
|
189
|
+
expect(html).not.toContain("Rentals");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("keeps the tab drawn when it is the one being shown, even with nothing in it", () => {
|
|
193
|
+
// A receipt email links straight at `?tab=rentals`. A strip that disowns the
|
|
194
|
+
// panel underneath it is worse than an empty tab.
|
|
195
|
+
const html = render({ rentals: [], tab: "rentals" });
|
|
196
|
+
|
|
197
|
+
expect(html).toContain("Rentals");
|
|
198
|
+
expect(html).toContain("You have not rented anything yet.");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -84,6 +84,9 @@
|
|
|
84
84
|
.order-1 {
|
|
85
85
|
order: 1;
|
|
86
86
|
}
|
|
87
|
+
.order-2 {
|
|
88
|
+
order: 2;
|
|
89
|
+
}
|
|
87
90
|
.container {
|
|
88
91
|
width: 100%;
|
|
89
92
|
@media (width >= 40rem) {
|
|
@@ -204,6 +207,9 @@
|
|
|
204
207
|
.shrink-0 {
|
|
205
208
|
flex-shrink: 0;
|
|
206
209
|
}
|
|
210
|
+
.grow {
|
|
211
|
+
flex-grow: 1;
|
|
212
|
+
}
|
|
207
213
|
.transform {
|
|
208
214
|
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
|
209
215
|
}
|
|
@@ -48,7 +48,12 @@ vi.mock("@paystack/inline-js", () => {
|
|
|
48
48
|
|
|
49
49
|
// Imported after the mock is registered (vitest hoists `vi.mock`, so a plain
|
|
50
50
|
// top-level import would be fine; this is only for readability).
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
openPaystackCheckout,
|
|
53
|
+
paystackReturnUrl,
|
|
54
|
+
hasPaystackSession,
|
|
55
|
+
isPaystackPopupOpen,
|
|
56
|
+
} from "../paystackCheckout";
|
|
52
57
|
|
|
53
58
|
const HOSTED = "https://checkout.paystack.com/abc123";
|
|
54
59
|
const RETURN = "https://artist.test/checkout/finalise?orderId=order-1";
|
|
@@ -264,3 +269,79 @@ describe("openPaystackCheckout", () => {
|
|
|
264
269
|
expect(location.href).toBe("https://artist.test/checkout");
|
|
265
270
|
});
|
|
266
271
|
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The popup says when it is up, so the dialog it was opened from can stand
|
|
275
|
+
* down (`ui/headless/dialog.tsx`).
|
|
276
|
+
*
|
|
277
|
+
* A flag left stuck ON is worse than no flag: every dialog on the site would
|
|
278
|
+
* stay non-modal for the rest of the session. So each terminal outcome is
|
|
279
|
+
* asserted separately rather than trusting one shared teardown.
|
|
280
|
+
*/
|
|
281
|
+
describe("openPaystackCheckout popup state", () => {
|
|
282
|
+
it("is closed before anything is opened", () => {
|
|
283
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("is open while the fan is deciding, and closed again once they pay", async () => {
|
|
287
|
+
let captured: ResumeOptions = {};
|
|
288
|
+
resumeBehaviour = (_code, options) => {
|
|
289
|
+
captured = options;
|
|
290
|
+
options.onLoad?.({});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
294
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
295
|
+
|
|
296
|
+
// The card form is on screen: the dialog behind it must not be holding the
|
|
297
|
+
// page's pointer events hostage.
|
|
298
|
+
expect(isPaystackPopupOpen()).toBe(true);
|
|
299
|
+
|
|
300
|
+
captured.onSuccess?.({ reference: "pay-uuid" });
|
|
301
|
+
await pending;
|
|
302
|
+
|
|
303
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("closes on a dismissal, so the fan gets a normal dialog back", async () => {
|
|
307
|
+
resumeBehaviour = (_code, options) => {
|
|
308
|
+
options.onLoad?.({});
|
|
309
|
+
options.onCancel?.();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
313
|
+
|
|
314
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("closes on a provider failure", async () => {
|
|
318
|
+
resumeBehaviour = (_code, options) => {
|
|
319
|
+
options.onLoad?.({});
|
|
320
|
+
options.onError?.({ message: "Card declined" });
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED }, { onError: vi.fn() });
|
|
324
|
+
|
|
325
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("closes when the popup never appears and the fan is sent to the hosted page", async () => {
|
|
329
|
+
resumeBehaviour = () => {};
|
|
330
|
+
|
|
331
|
+
const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
332
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
333
|
+
await pending;
|
|
334
|
+
|
|
335
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("closes when resumeTransaction throws before the popup exists", async () => {
|
|
339
|
+
resumeBehaviour = () => {
|
|
340
|
+
throw new Error("popup blew up");
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
|
|
344
|
+
|
|
345
|
+
expect(isPaystackPopupOpen()).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -129,6 +129,45 @@ const modalIsPresent = (): boolean => {
|
|
|
129
129
|
});
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Is a Paystack popup on screen right now?
|
|
134
|
+
*
|
|
135
|
+
* Paystack appends its overlay to `document.body`, which puts it OUTSIDE any
|
|
136
|
+
* dialog the pay button was rendered in. A modal dialog is built to make
|
|
137
|
+
* exactly that unreachable: Radix sets `pointer-events: none` on the body and
|
|
138
|
+
* traps focus inside its own content, so the popup renders on top, looks
|
|
139
|
+
* usable, and swallows every click. A fan on a code website could not pay for
|
|
140
|
+
* tickets, a course, a coaching slot, a film, a donation or an offer, because
|
|
141
|
+
* all six open Paystack from inside a dialog.
|
|
142
|
+
*
|
|
143
|
+
* So the popup announces itself and the dialog primitives get out of the way
|
|
144
|
+
* (`ui/headless/dialog.tsx`). It is published from HERE rather than from a
|
|
145
|
+
* React module because this file is the one funnel every pillar already goes
|
|
146
|
+
* through, and a second place that knows when the popup is up is a second place
|
|
147
|
+
* to forget to clear it.
|
|
148
|
+
*/
|
|
149
|
+
let popupOpen = false;
|
|
150
|
+
const popupListeners = new Set<() => void>();
|
|
151
|
+
|
|
152
|
+
/** For `useSyncExternalStore`, and for anything else that needs the answer now. */
|
|
153
|
+
export function isPaystackPopupOpen(): boolean {
|
|
154
|
+
return popupOpen;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Subscribe to popup open/close. Returns the unsubscribe. */
|
|
158
|
+
export function subscribeToPaystackPopup(listener: () => void): () => void {
|
|
159
|
+
popupListeners.add(listener);
|
|
160
|
+
return () => {
|
|
161
|
+
popupListeners.delete(listener);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const setPopupOpen = (next: boolean): void => {
|
|
166
|
+
if (popupOpen === next) return;
|
|
167
|
+
popupOpen = next;
|
|
168
|
+
for (const listener of popupListeners) listener();
|
|
169
|
+
};
|
|
170
|
+
|
|
132
171
|
const errorMessageOf = (error: unknown): string | undefined => {
|
|
133
172
|
if (typeof error === "string") return error;
|
|
134
173
|
const message = (error as { message?: unknown } | null)?.message;
|
|
@@ -222,6 +261,10 @@ export async function openPaystackCheckout(
|
|
|
222
261
|
if (settled) return false;
|
|
223
262
|
settled = true;
|
|
224
263
|
stopWatching();
|
|
264
|
+
// Every terminal outcome passes through here, which is what makes "the
|
|
265
|
+
// popup is up" impossible to leave stuck on: paid, dismissed, failed and
|
|
266
|
+
// fell-back-to-the-hosted-page all clear it.
|
|
267
|
+
setPopupOpen(false);
|
|
225
268
|
resolve(outcome);
|
|
226
269
|
return true;
|
|
227
270
|
};
|
|
@@ -237,6 +280,10 @@ export async function openPaystackCheckout(
|
|
|
237
280
|
};
|
|
238
281
|
|
|
239
282
|
try {
|
|
283
|
+
// Announced BEFORE the iframe exists, not on `onLoad`. The dialog around
|
|
284
|
+
// the pay button has to have released the page by the time the popup is
|
|
285
|
+
// painted, and a fan taps the first card field the instant they see it.
|
|
286
|
+
setPopupOpen(true);
|
|
240
287
|
new PaystackPop().resumeTransaction(accessCode, {
|
|
241
288
|
onSuccess: (transaction) => succeed(transaction ?? {}),
|
|
242
289
|
// Closing the popup leaves the charge exactly where it was. Nothing is
|
|
@@ -10,22 +10,15 @@ export function buildOrganizationSchema(webPage: WebPage, origin: string) {
|
|
|
10
10
|
"@type": "Organization",
|
|
11
11
|
name: webPage.profile.name,
|
|
12
12
|
url: origin,
|
|
13
|
-
...(webPage.themeSettings.header?.logo
|
|
14
|
-
|
|
15
|
-
: {}),
|
|
16
|
-
...(socialLinks.length > 0
|
|
17
|
-
? { sameAs: socialLinks.map((l: { href: string }) => l.href).filter(Boolean) }
|
|
18
|
-
: {}),
|
|
13
|
+
...(webPage.themeSettings.header?.logo ? { logo: webPage.themeSettings.header.logo } : {}),
|
|
14
|
+
...(socialLinks.length > 0 ? { sameAs: socialLinks.map((l: { href: string }) => l.href).filter(Boolean) } : {}),
|
|
19
15
|
};
|
|
20
16
|
}
|
|
21
17
|
|
|
22
18
|
/**
|
|
23
19
|
* Generates WebPage JSON-LD.
|
|
24
20
|
*/
|
|
25
|
-
export function buildWebPageSchema(
|
|
26
|
-
webPage: WebPage,
|
|
27
|
-
canonicalUrl: string,
|
|
28
|
-
) {
|
|
21
|
+
export function buildWebPageSchema(webPage: WebPage, canonicalUrl: string) {
|
|
29
22
|
return {
|
|
30
23
|
"@context": "https://schema.org",
|
|
31
24
|
"@type": "WebPage",
|
|
@@ -181,10 +174,7 @@ export function buildEntitySchema(input: EntitySchemaInput): Record<string, unkn
|
|
|
181
174
|
|
|
182
175
|
const { aggregate } = input;
|
|
183
176
|
const hasRatings =
|
|
184
|
-
!!aggregate &&
|
|
185
|
-
aggregate.reviewCount > 0 &&
|
|
186
|
-
Number.isFinite(aggregate.avgRating) &&
|
|
187
|
-
aggregate.avgRating > 0;
|
|
177
|
+
!!aggregate && aggregate.reviewCount > 0 && Number.isFinite(aggregate.avgRating) && aggregate.avgRating > 0;
|
|
188
178
|
|
|
189
179
|
const reviews = (input.reviews ?? [])
|
|
190
180
|
.filter((r) => r.rating >= 1 && r.rating <= 5)
|
|
@@ -439,12 +429,93 @@ export function buildServiceSchema(
|
|
|
439
429
|
aggregate: service.reviewAggregate ?? null,
|
|
440
430
|
reviews: options.reviews,
|
|
441
431
|
offer:
|
|
442
|
-
options.currency && price !== undefined
|
|
443
|
-
? { currency: options.currency, price, availability: "InStock" }
|
|
444
|
-
: null,
|
|
432
|
+
options.currency && price !== undefined ? { currency: options.currency, price, availability: "InStock" } : null,
|
|
445
433
|
});
|
|
446
434
|
}
|
|
447
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Structural view of `GET /public/films/:slug`. `PublicFilmDetail` satisfies it.
|
|
438
|
+
*
|
|
439
|
+
* Deliberately narrow, and deliberately NOT the whole payload: JSON-LD is
|
|
440
|
+
* emitted into an anonymous SSR document, so anything named here is something a
|
|
441
|
+
* crawler and a signed-out visitor both get to read. Nothing about a rental,
|
|
442
|
+
* a session or an episode's storage belongs in it.
|
|
443
|
+
*/
|
|
444
|
+
export interface FilmSchemaSource {
|
|
445
|
+
kind: "film" | "series";
|
|
446
|
+
title: string;
|
|
447
|
+
description?: string | null;
|
|
448
|
+
cover?: { url: string } | null;
|
|
449
|
+
currency: string;
|
|
450
|
+
rentalPriceCents?: number | null;
|
|
451
|
+
publishedAt?: string | null;
|
|
452
|
+
episodes?: { durationSec?: number | null }[] | null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** ISO 8601 duration (`PT1H42M`) from whole seconds, or undefined when unknown. */
|
|
456
|
+
const isoDuration = (seconds?: number | null): string | undefined => {
|
|
457
|
+
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
458
|
+
const whole = Math.floor(seconds);
|
|
459
|
+
const hours = Math.floor(whole / 3600);
|
|
460
|
+
const minutes = Math.round((whole % 3600) / 60);
|
|
461
|
+
if (!hours && !minutes) return undefined;
|
|
462
|
+
return `PT${hours ? `${hours}H` : ""}${minutes ? `${minutes}M` : ""}`;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* schema.org/Movie or /TVSeries for a rentable title.
|
|
467
|
+
*
|
|
468
|
+
* Built by hand rather than through `buildEntitySchema`, whose `type` union is
|
|
469
|
+
* closed over the three reviewable sellables. Widening that union to carry two
|
|
470
|
+
* types that have no review surface would make every caller of it responsible
|
|
471
|
+
* for cases it can never see.
|
|
472
|
+
*
|
|
473
|
+
* The offer is the title's rental price, which is the only price there is: a
|
|
474
|
+
* rental always buys the whole title. There is no cheaper way in to quote, and
|
|
475
|
+
* a title with no price gets no `offers` node rather than a free one.
|
|
476
|
+
*/
|
|
477
|
+
export function buildFilmSchema(
|
|
478
|
+
film: FilmSchemaSource | null | undefined,
|
|
479
|
+
options: SellableSchemaOptions = {},
|
|
480
|
+
): Record<string, unknown> | null {
|
|
481
|
+
const name = film?.title?.trim();
|
|
482
|
+
if (!film || !name) return null;
|
|
483
|
+
|
|
484
|
+
const priceCents =
|
|
485
|
+
typeof film.rentalPriceCents === "number" && Number.isFinite(film.rentalPriceCents) ? film.rentalPriceCents : null;
|
|
486
|
+
const currency = options.currency ?? film.currency;
|
|
487
|
+
const description = toSchemaText(film.description);
|
|
488
|
+
// A film's runtime is its single episode's. A series has no one runtime, so it
|
|
489
|
+
// gets none rather than an invented average.
|
|
490
|
+
const duration = film.kind === "film" ? isoDuration(film.episodes?.[0]?.durationSec) : undefined;
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"@context": "https://schema.org",
|
|
494
|
+
"@type": film.kind === "series" ? "TVSeries" : "Movie",
|
|
495
|
+
name,
|
|
496
|
+
...(description ? { description } : {}),
|
|
497
|
+
...(film.cover?.url ? { image: film.cover.url } : {}),
|
|
498
|
+
...(options.url ? { url: options.url } : {}),
|
|
499
|
+
...(options.brand ? { provider: { "@type": "Organization", name: options.brand } } : {}),
|
|
500
|
+
...(film.publishedAt ? { datePublished: film.publishedAt } : {}),
|
|
501
|
+
...(duration ? { duration } : {}),
|
|
502
|
+
...(film.kind === "series" && film.episodes?.length ? { numberOfEpisodes: film.episodes.length } : {}),
|
|
503
|
+
...(currency && priceCents != null
|
|
504
|
+
? {
|
|
505
|
+
offers: {
|
|
506
|
+
"@type": "Offer",
|
|
507
|
+
priceCurrency: currency,
|
|
508
|
+
price: money(priceCents / 100),
|
|
509
|
+
availability: "https://schema.org/InStock",
|
|
510
|
+
// A rental, not a sale. Saying so is what keeps a $2 line from
|
|
511
|
+
// reading as the price of owning the film.
|
|
512
|
+
category: "Rental",
|
|
513
|
+
},
|
|
514
|
+
}
|
|
515
|
+
: {}),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
448
519
|
/**
|
|
449
520
|
* The `scripts` entry a TanStack `head()` needs for a JSON-LD node. Returns an
|
|
450
521
|
* empty array for a null schema, so it can be spread unconditionally:
|