@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
package/src/server/index.ts
CHANGED
|
@@ -50,16 +50,17 @@ import type {
|
|
|
50
50
|
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
51
51
|
// SEO context for structured data. Pure functions over plain data, so this is
|
|
52
52
|
// safe in the React-free server entry.
|
|
53
|
-
import {
|
|
54
|
-
seoContextFromSiteConfig,
|
|
55
|
-
type ReviewSchemaReview,
|
|
56
|
-
type SiteSeoContext,
|
|
57
|
-
} from "../utils/structuredData";
|
|
53
|
+
import { seoContextFromSiteConfig, type ReviewSchemaReview, type SiteSeoContext } from "../utils/structuredData";
|
|
58
54
|
// Also React-free, so it belongs in this entry rather than being re-derived by
|
|
59
55
|
// each route that needs share tags.
|
|
60
56
|
import { buildHeadMeta } from "../utils/headMeta";
|
|
61
57
|
export type { SiteSeoContext };
|
|
62
58
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
59
|
+
// Type-only, so nothing from the React data layer reaches this React-free entry.
|
|
60
|
+
// Re-exported so a route loader can name the shape it got back without importing
|
|
61
|
+
// past `@tribe-nest/forge/server`.
|
|
62
|
+
import type { FilmCatalogPage, PublicFilmDetail, PublicFilmEpisode, PublicFilmSummary } from "../data/queries/useFilms";
|
|
63
|
+
export type { FilmCatalogPage, PublicFilmDetail, PublicFilmEpisode, PublicFilmSummary };
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* One public-API GET, KEEPING the failure reason. `getJson` (below) throws that
|
|
@@ -87,7 +88,11 @@ async function getJsonResult<T>(
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/** GET a public endpoint and parse JSON, returning `null` on any failure. */
|
|
90
|
-
async function getJson<T>(
|
|
91
|
+
async function getJson<T>(
|
|
92
|
+
apiUrl: string,
|
|
93
|
+
path: string,
|
|
94
|
+
params?: Record<string, string | undefined>,
|
|
95
|
+
): Promise<T | null> {
|
|
91
96
|
const res = await getJsonResult<T>(apiUrl, path, params);
|
|
92
97
|
return res.ok ? res.data : null;
|
|
93
98
|
}
|
|
@@ -170,10 +175,7 @@ export async function fetchEntityReviewsServer(opts: {
|
|
|
170
175
|
* site config is invisible there. Pair it with the entity fetch in one
|
|
171
176
|
* `Promise.all` and the extra round trip costs nothing wall-clock.
|
|
172
177
|
*/
|
|
173
|
-
export async function fetchSeoContextServer(opts: {
|
|
174
|
-
apiUrl: string;
|
|
175
|
-
profileId?: string;
|
|
176
|
-
}): Promise<SiteSeoContext> {
|
|
178
|
+
export async function fetchSeoContextServer(opts: { apiUrl: string; profileId?: string }): Promise<SiteSeoContext> {
|
|
177
179
|
return seoContextFromSiteConfig(await fetchSiteConfig(opts));
|
|
178
180
|
}
|
|
179
181
|
|
|
@@ -272,7 +274,11 @@ export async function fetchSiteBootstrap(opts: {
|
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
/** Fetch a single event by id or slug for SSR. */
|
|
275
|
-
export function fetchEventServer(opts: {
|
|
277
|
+
export function fetchEventServer(opts: {
|
|
278
|
+
apiUrl: string;
|
|
279
|
+
profileId?: string;
|
|
280
|
+
idOrSlug: string;
|
|
281
|
+
}): Promise<IEvent | null> {
|
|
276
282
|
return getJson<IEvent>(opts.apiUrl, `/public/events/${encodeURIComponent(opts.idOrSlug)}`, {
|
|
277
283
|
profileId: opts.profileId,
|
|
278
284
|
});
|
|
@@ -366,6 +372,57 @@ export function fetchCourseServer(opts: {
|
|
|
366
372
|
});
|
|
367
373
|
}
|
|
368
374
|
|
|
375
|
+
// --- Films and series (SSR) ---
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The published film/series catalogue, for SSR of `/i/films`.
|
|
379
|
+
*
|
|
380
|
+
* Returns the whole page envelope rather than just the rows, because `enabled`
|
|
381
|
+
* is load-bearing: with the film-rentals switch off the API answers 200 with an
|
|
382
|
+
* empty list and `enabled: false`, and a catalogue page that cannot tell that
|
|
383
|
+
* apart from "this creator has no films yet" would draw the wrong empty state.
|
|
384
|
+
* A network failure degrades to `enabled: false` with an empty list, which is
|
|
385
|
+
* the same safe nothing.
|
|
386
|
+
*/
|
|
387
|
+
export async function fetchFilmsServer(opts: {
|
|
388
|
+
apiUrl: string;
|
|
389
|
+
profileId?: string;
|
|
390
|
+
page?: number;
|
|
391
|
+
limit?: number;
|
|
392
|
+
kind?: "film" | "series";
|
|
393
|
+
}): Promise<FilmCatalogPage> {
|
|
394
|
+
const res = await getJson<FilmCatalogPage>(opts.apiUrl, "/public/films", {
|
|
395
|
+
profileId: opts.profileId,
|
|
396
|
+
page: String(opts.page ?? 1),
|
|
397
|
+
limit: String(opts.limit ?? 20),
|
|
398
|
+
kind: opts.kind,
|
|
399
|
+
});
|
|
400
|
+
return res ?? { data: [], total: 0, page: opts.page ?? 1, limit: opts.limit ?? 20, enabled: false };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* One title by slug, for SSR of `/i/films/$slug`.
|
|
405
|
+
*
|
|
406
|
+
* Deliberately UNAUTHENTICATED, and that is the paywall boundary: the loader
|
|
407
|
+
* runs on the server with no fan session, so `viewerRental` is always null here
|
|
408
|
+
* and no manifest URL, key URL or `hlsPrefix` can reach the SSR payload. The
|
|
409
|
+
* signed-in view is fetched from the browser by `useFilm`, which is the only
|
|
410
|
+
* place a rental is ever resolved. Do not "improve" this by forwarding a
|
|
411
|
+
* cookie: a leak the UI happens not to render is still a leak.
|
|
412
|
+
*
|
|
413
|
+
* `null` for a draft, an archived title, an unknown slug, another tenant's slug,
|
|
414
|
+
* or while the feature switch is off - all four indistinguishable on purpose.
|
|
415
|
+
*/
|
|
416
|
+
export function fetchFilmServer(opts: {
|
|
417
|
+
apiUrl: string;
|
|
418
|
+
profileId?: string;
|
|
419
|
+
slug: string;
|
|
420
|
+
}): Promise<PublicFilmDetail | null> {
|
|
421
|
+
return getJson<PublicFilmDetail>(opts.apiUrl, `/public/films/${encodeURIComponent(opts.slug)}`, {
|
|
422
|
+
profileId: opts.profileId,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
369
426
|
/** Fetch a single coaching product by id or slug for SSR. */
|
|
370
427
|
export function fetchCoachingProductServer(opts: {
|
|
371
428
|
apiUrl: string;
|
|
@@ -403,7 +460,9 @@ export function fetchBlogCategoryServer(opts: {
|
|
|
403
460
|
|
|
404
461
|
/** All podcast shows for the profile, for SSR of the podcasts index. */
|
|
405
462
|
export async function fetchPodcastsServer(opts: { apiUrl: string; profileId?: string }): Promise<PodcastShow[]> {
|
|
406
|
-
return (
|
|
463
|
+
return (
|
|
464
|
+
(await getJson<PodcastShow[]>(opts.apiUrl, `/public/blog/posts/podcasts`, { profileId: opts.profileId })) ?? []
|
|
465
|
+
);
|
|
407
466
|
}
|
|
408
467
|
|
|
409
468
|
/** A show's episodes (audio posts) in feed order, for SSR of a show/episode page. */
|
|
@@ -459,11 +518,11 @@ export function fetchCollectionQueryServer(opts: {
|
|
|
459
518
|
query?: CollectionQuery;
|
|
460
519
|
}): Promise<CollectionSearchResult | null> {
|
|
461
520
|
const { scope, query } = splitCollectionQuery(opts.query);
|
|
462
|
-
return postJson<CollectionSearchResult>(
|
|
463
|
-
opts.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
);
|
|
521
|
+
return postJson<CollectionSearchResult>(opts.apiUrl, `/public/collections/${encodeURIComponent(opts.slug)}/query`, {
|
|
522
|
+
profileId: opts.profileId,
|
|
523
|
+
scope,
|
|
524
|
+
query,
|
|
525
|
+
});
|
|
467
526
|
}
|
|
468
527
|
|
|
469
528
|
/** Fetch a single published entry by slug for SSR. */
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { act, render, fireEvent } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bug this file exists for: a fan on a code website could not pay with
|
|
7
|
+
* Paystack from inside a dialog.
|
|
8
|
+
*
|
|
9
|
+
* Paystack appends its checkout overlay to `document.body`. A modal Radix
|
|
10
|
+
* dialog sets `pointer-events: none` on that same body and traps focus inside
|
|
11
|
+
* its own content, so the popup arrives on top of the page, looks completely
|
|
12
|
+
* normal, and eats every click and keystroke aimed at it. The fan sees the card
|
|
13
|
+
* form and cannot type in it. Event tickets, courses, coaching, films,
|
|
14
|
+
* donations and offers all open the popup from inside one of these dialogs, so
|
|
15
|
+
* every one of them was unpayable on Paystack.
|
|
16
|
+
*
|
|
17
|
+
* It went unnoticed because the only inline-checkout UI coverage was the
|
|
18
|
+
* payment-link page, which is a plain page with no dialog on it.
|
|
19
|
+
*
|
|
20
|
+
* jsdom rather than `renderToStaticMarkup`: the whole behaviour is mount
|
|
21
|
+
* effects (Radix writes the body style on mount and restores it on unmount), so
|
|
22
|
+
* there is nothing to assert without a real DOM lifecycle.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type ResumeOptions = {
|
|
26
|
+
onSuccess?: (t: { reference?: string }) => void;
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
onLoad?: (r: unknown) => void;
|
|
29
|
+
onError?: (e: unknown) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let captured: ResumeOptions = {};
|
|
33
|
+
|
|
34
|
+
vi.mock("@paystack/inline-js", () => ({
|
|
35
|
+
default: class FakePaystackPop {
|
|
36
|
+
resumeTransaction(_accessCode: string, options: ResumeOptions = {}) {
|
|
37
|
+
captured = options;
|
|
38
|
+
options.onLoad?.({});
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
cancelTransaction() {}
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { openPaystackCheckout } from "../../../utils/paystackCheckout";
|
|
46
|
+
import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle } from "../dialog";
|
|
47
|
+
|
|
48
|
+
/** jsdom refuses real navigation, so `location` is replaced with a plain bag. */
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
captured = {};
|
|
51
|
+
Object.defineProperty(window, "location", {
|
|
52
|
+
value: { href: "https://artist.test/tickets" },
|
|
53
|
+
writable: true,
|
|
54
|
+
configurable: true,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function Checkout({ open, onOpenChange }: { open: boolean; onOpenChange?: (next: boolean) => void }) {
|
|
59
|
+
return (
|
|
60
|
+
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
|
61
|
+
<DialogPortal>
|
|
62
|
+
<DialogOverlay />
|
|
63
|
+
<DialogContent aria-describedby={undefined}>
|
|
64
|
+
<DialogTitle>Tickets</DialogTitle>
|
|
65
|
+
<button type="button">Pay</button>
|
|
66
|
+
</DialogContent>
|
|
67
|
+
</DialogPortal>
|
|
68
|
+
</DialogRoot>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Open the popup the way a pay button does, and leave it open.
|
|
74
|
+
*
|
|
75
|
+
* The attempt is handed back inside an object rather than as the promise
|
|
76
|
+
* itself: `await` flattens a promise returned from an async function, and this
|
|
77
|
+
* one deliberately does not settle until the fan acts.
|
|
78
|
+
*/
|
|
79
|
+
async function openPopup(): Promise<{ attempt: Promise<unknown> }> {
|
|
80
|
+
let attempt: Promise<unknown> = Promise.resolve();
|
|
81
|
+
await act(async () => {
|
|
82
|
+
attempt = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: "https://checkout.paystack.com/abc" });
|
|
83
|
+
// Let the dynamic import of the SDK land.
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
});
|
|
87
|
+
return { attempt };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Close the popup and let the attempt finish, as a dismissal does. */
|
|
91
|
+
async function dismissPopup(attempt: Promise<unknown>): Promise<void> {
|
|
92
|
+
await act(async () => {
|
|
93
|
+
captured.onCancel?.();
|
|
94
|
+
await attempt;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the dialog and the Paystack popup", () => {
|
|
99
|
+
it("locks the page while it is an ordinary modal", () => {
|
|
100
|
+
// The baseline, asserted so the rest of this file cannot pass by the
|
|
101
|
+
// dialog simply never being modal in the first place.
|
|
102
|
+
render(<Checkout open />);
|
|
103
|
+
|
|
104
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("REGRESSION: releases the page the moment the popup opens", async () => {
|
|
108
|
+
render(<Checkout open />);
|
|
109
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
110
|
+
|
|
111
|
+
const { attempt } = await openPopup();
|
|
112
|
+
|
|
113
|
+
// The fan can now reach the card form. This one assertion is the bug.
|
|
114
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
115
|
+
|
|
116
|
+
await dismissPopup(attempt);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("stays out of the way after a dismissal rather than remounting the checkout under the fan", async () => {
|
|
120
|
+
// Radix renders modal and non-modal content as different components, so
|
|
121
|
+
// flipping back would remount everything inside the dialog in front of
|
|
122
|
+
// somebody who just closed the popup and is about to try again.
|
|
123
|
+
render(<Checkout open />);
|
|
124
|
+
const { attempt } = await openPopup();
|
|
125
|
+
|
|
126
|
+
await dismissPopup(attempt);
|
|
127
|
+
|
|
128
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("is a normal modal again the next time it opens", async () => {
|
|
132
|
+
// Closed by its owner setting `open` to false, with no `onOpenChange` in
|
|
133
|
+
// sight. That is how the ticket modal closes when a buyer sends their
|
|
134
|
+
// selection to the cart, and watching only `onOpenChange` left those
|
|
135
|
+
// dialogs non-modal for the rest of the visit.
|
|
136
|
+
const { rerender } = render(<Checkout open />);
|
|
137
|
+
const { attempt } = await openPopup();
|
|
138
|
+
await dismissPopup(attempt);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
rerender(<Checkout open={false} />);
|
|
142
|
+
});
|
|
143
|
+
await act(async () => {
|
|
144
|
+
rerender(<Checkout open />);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(document.body.style.pointerEvents).toBe("none");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("REGRESSION: Escape closes the payment window, not the checkout behind it", async () => {
|
|
151
|
+
// Non-modal dialogs still dismiss on Escape, and a fan pressing it means
|
|
152
|
+
// "close Paystack". Taking the checkout down with it loses the order.
|
|
153
|
+
const onOpenChange = vi.fn();
|
|
154
|
+
render(<Checkout open onOpenChange={onOpenChange} />);
|
|
155
|
+
const { attempt } = await openPopup();
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
162
|
+
|
|
163
|
+
await dismissPopup(attempt);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("REGRESSION: a click on Paystack's own backdrop does not close the checkout", async () => {
|
|
167
|
+
// Paystack's overlay is a node in THIS document, outside the dialog
|
|
168
|
+
// content, so a non-modal dialog reads a tap beside the card form as an
|
|
169
|
+
// outside interaction and dismisses.
|
|
170
|
+
const onOpenChange = vi.fn();
|
|
171
|
+
render(<Checkout open onOpenChange={onOpenChange} />);
|
|
172
|
+
const { attempt } = await openPopup();
|
|
173
|
+
|
|
174
|
+
const backdrop = document.createElement("div");
|
|
175
|
+
document.body.appendChild(backdrop);
|
|
176
|
+
await act(async () => {
|
|
177
|
+
fireEvent.pointerDown(backdrop);
|
|
178
|
+
fireEvent.mouseDown(backdrop);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
182
|
+
|
|
183
|
+
await dismissPopup(attempt);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -1,14 +1,149 @@
|
|
|
1
|
-
// Unstyled dialog primitives — thin
|
|
1
|
+
// Unstyled dialog primitives — a thin wrapper over Radix Dialog so the headless
|
|
2
2
|
// blocks get portal + focus-trap + a11y + keyboard for free, with zero styling.
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
//
|
|
4
|
+
// Thin, but no longer a bare re-export: `DialogRoot` and `DialogContent` stand
|
|
5
|
+
// down while a Paystack popup is on screen. See `usePaystackPopupOpen` below
|
|
6
|
+
// for why that is not optional.
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import {
|
|
9
|
+
Root,
|
|
5
10
|
Trigger as DialogTrigger,
|
|
6
11
|
Portal as DialogPortal,
|
|
7
12
|
Overlay as DialogOverlay,
|
|
8
|
-
Content
|
|
13
|
+
Content,
|
|
9
14
|
Close as DialogClose,
|
|
10
15
|
Title as DialogTitle,
|
|
11
16
|
Description as DialogDescription,
|
|
12
17
|
} from "@radix-ui/react-dialog";
|
|
18
|
+
import { isPaystackPopupOpen, subscribeToPaystackPopup } from "../../utils/paystackCheckout";
|
|
13
19
|
|
|
20
|
+
export { DialogTrigger, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription };
|
|
14
21
|
export { Slot } from "@radix-ui/react-slot";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is Paystack's popup on screen right now?
|
|
25
|
+
*
|
|
26
|
+
* Exported for a site that builds its own dialog instead of using these
|
|
27
|
+
* primitives: the popup lives on `document.body`, so ANY modal layer that locks
|
|
28
|
+
* the page will lock the popup out with it.
|
|
29
|
+
*/
|
|
30
|
+
export function usePaystackPopupOpen(): boolean {
|
|
31
|
+
return React.useSyncExternalStore(
|
|
32
|
+
subscribeToPaystackPopup,
|
|
33
|
+
isPaystackPopupOpen,
|
|
34
|
+
// SSR: no popup can be open on the server, and guessing "yes" would render
|
|
35
|
+
// a non-modal dialog that turns modal on hydration.
|
|
36
|
+
() => false,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The dialog root, which STOPS BEING MODAL once a Paystack popup opens inside
|
|
42
|
+
* it.
|
|
43
|
+
*
|
|
44
|
+
* A modal Radix dialog sets `pointer-events: none` on `document.body` and traps
|
|
45
|
+
* focus in its own content. Paystack appends its overlay to the body, outside
|
|
46
|
+
* that content, so both of those apply to the popup: the fan sees the card form
|
|
47
|
+
* on top of everything, clicks it, and nothing happens — the dialog they opened
|
|
48
|
+
* it from is eating every event. Every Paystack pillar on a code website (event
|
|
49
|
+
* tickets, courses, coaching, films, donations, offers) opens the popup from
|
|
50
|
+
* inside one of these, so all six were unpayable.
|
|
51
|
+
*
|
|
52
|
+
* Non-modal content drops both behaviours, which is exactly the amount of
|
|
53
|
+
* getting-out-of-the-way required, and the popup's own overlay is still on top
|
|
54
|
+
* of the page at a z-index nothing here competes with.
|
|
55
|
+
*
|
|
56
|
+
* ## Why it does not switch back when the popup closes
|
|
57
|
+
*
|
|
58
|
+
* Radix renders modal and non-modal content as DIFFERENT components, so
|
|
59
|
+
* flipping the flag remounts everything inside the dialog. Once is free: the
|
|
60
|
+
* popup is covering the screen at that moment and the pillar hooks that hold
|
|
61
|
+
* the checkout state live OUTSIDE the dialog content. Flipping back on dismissal
|
|
62
|
+
* would remount a second time in front of the fan, resetting whatever they had
|
|
63
|
+
* typed into the step they are looking at. So the dialog stays non-modal until
|
|
64
|
+
* it CLOSES, and opens modal again next time.
|
|
65
|
+
*
|
|
66
|
+
* A host that passes `modal={false}` keeps it: this only ever removes modality.
|
|
67
|
+
*/
|
|
68
|
+
export function DialogRoot({
|
|
69
|
+
modal,
|
|
70
|
+
open,
|
|
71
|
+
onOpenChange,
|
|
72
|
+
...props
|
|
73
|
+
}: React.ComponentProps<typeof Root>): React.ReactElement {
|
|
74
|
+
const popupOpen = usePaystackPopupOpen();
|
|
75
|
+
const [stoodDown, setStoodDown] = React.useState(false);
|
|
76
|
+
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (popupOpen) setStoodDown(true);
|
|
79
|
+
}, [popupOpen]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Closing ends the stand-down, so the NEXT time this dialog opens it is an
|
|
83
|
+
* ordinary modal again.
|
|
84
|
+
*
|
|
85
|
+
* Both routes are covered on purpose. A CONTROLLED dialog can be closed by
|
|
86
|
+
* its owner setting `open` to false without `onOpenChange` ever firing (the
|
|
87
|
+
* ticket modal does exactly that when the buyer sends a selection to the
|
|
88
|
+
* cart), and an UNCONTROLLED one has no `open` prop to watch.
|
|
89
|
+
*/
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (open === false) setStoodDown(false);
|
|
92
|
+
}, [open]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Root
|
|
96
|
+
{...props}
|
|
97
|
+
open={open}
|
|
98
|
+
modal={modal === false ? false : !stoodDown}
|
|
99
|
+
onOpenChange={(next) => {
|
|
100
|
+
if (!next) setStoodDown(false);
|
|
101
|
+
onOpenChange?.(next);
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The dialog body, which refuses to fight the Paystack popup for focus or for
|
|
109
|
+
* the fan's clicks.
|
|
110
|
+
*
|
|
111
|
+
* Three defaults have to be suspended while the popup is up, and all three are
|
|
112
|
+
* the dialog doing its job in a situation where its job is wrong:
|
|
113
|
+
*
|
|
114
|
+
* - **Auto-focus on open.** A non-modal remount focuses its first control,
|
|
115
|
+
* which would yank the caret out of the card field the fan is typing in.
|
|
116
|
+
* - **Dismiss on outside interaction.** Paystack's backdrop is a click in THIS
|
|
117
|
+
* document, outside this content, so closing the popup by tapping beside it
|
|
118
|
+
* would take the checkout down with it.
|
|
119
|
+
* - **Dismiss on Escape.** Same thing with the key that means "close the
|
|
120
|
+
* payment window".
|
|
121
|
+
*
|
|
122
|
+
* Each host handler still runs; the suspension is applied after it, so a
|
|
123
|
+
* surface that wants its own behaviour keeps it everywhere except here.
|
|
124
|
+
*/
|
|
125
|
+
export const DialogContent = React.forwardRef<
|
|
126
|
+
React.ElementRef<typeof Content>,
|
|
127
|
+
React.ComponentPropsWithoutRef<typeof Content>
|
|
128
|
+
>(function DialogContent({ onOpenAutoFocus, onInteractOutside, onEscapeKeyDown, ...props }, ref) {
|
|
129
|
+
const popupOpen = usePaystackPopupOpen();
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Content
|
|
133
|
+
{...props}
|
|
134
|
+
ref={ref}
|
|
135
|
+
onOpenAutoFocus={(event) => {
|
|
136
|
+
onOpenAutoFocus?.(event);
|
|
137
|
+
if (popupOpen) event.preventDefault();
|
|
138
|
+
}}
|
|
139
|
+
onInteractOutside={(event) => {
|
|
140
|
+
onInteractOutside?.(event);
|
|
141
|
+
if (popupOpen) event.preventDefault();
|
|
142
|
+
}}
|
|
143
|
+
onEscapeKeyDown={(event) => {
|
|
144
|
+
onEscapeKeyDown?.(event);
|
|
145
|
+
if (popupOpen) event.preventDefault();
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
2
|
+
import type { FilmWatermarkPayload } from "../../../data/queries/useFilmPlaybackSession";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The dynamic viewer overlay: this renter's name, email and rental reference,
|
|
6
|
+
* drawn over the player for a few seconds at a time in a moving position.
|
|
7
|
+
*
|
|
8
|
+
* ## What this layer does, and what it does not
|
|
9
|
+
*
|
|
10
|
+
* It deters the screen-record-and-share leak by NAMING the leaker. It stops
|
|
11
|
+
* nobody from recording, and it is the one protection layer a custom player can
|
|
12
|
+
* simply omit - which is why it ships as a droppable component rather than as
|
|
13
|
+
* something welded into a styled block. Keeping the layer costs one component;
|
|
14
|
+
* dropping it costs per-viewer traceability, and a leaked recording is then
|
|
15
|
+
* still traceable to the film (the platform watermark is burned into the pixels
|
|
16
|
+
* by the transcoder) but no longer to the person.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it moves
|
|
19
|
+
*
|
|
20
|
+
* Constant placement invites cropping: a fixed corner is one rectangle to cut
|
|
21
|
+
* off, and a recording cropped once is clean for ever. Randomised position and
|
|
22
|
+
* timing contaminate the WHOLE recording instead, so there is no single edit
|
|
23
|
+
* that removes it.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it watches itself
|
|
26
|
+
*
|
|
27
|
+
* A viewer with dev tools open can delete the node or set `display: none` on it.
|
|
28
|
+
* The overlay observes its own subtree and re-asserts the style it was given, so
|
|
29
|
+
* removing it takes more than one click. This is a speed bump, not a control: it
|
|
30
|
+
* is a client-side layer and everything client-side is ultimately editable. The
|
|
31
|
+
* layers that actually hold are on the server.
|
|
32
|
+
*
|
|
33
|
+
* Mount it INSIDE the element that has the video, positioned, so it shares the
|
|
34
|
+
* player surface. A sibling div is trivially hidden and, worse, is not captured
|
|
35
|
+
* by a screen recording of the video element alone.
|
|
36
|
+
*/
|
|
37
|
+
export interface FilmWatermarkProps extends Partial<FilmWatermarkPayload> {
|
|
38
|
+
/** How often a new placement appears, in ms. Default 45s, jittered. */
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
/** How long each placement stays up, in ms. Default 6s. */
|
|
41
|
+
visibleMs?: number;
|
|
42
|
+
/** 0..1. Default 0.42 - readable in a recording, unobtrusive while watching. */
|
|
43
|
+
opacity?: number;
|
|
44
|
+
className?: string;
|
|
45
|
+
/** Merged over the computed placement. Does not survive the tamper check. */
|
|
46
|
+
style?: CSSProperties;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The nine placements, as percentages of the player surface. Corners are avoided: they crop first. */
|
|
50
|
+
const PLACEMENTS: { top: string; left: string }[] = [
|
|
51
|
+
{ top: "12%", left: "8%" },
|
|
52
|
+
{ top: "12%", left: "52%" },
|
|
53
|
+
{ top: "38%", left: "18%" },
|
|
54
|
+
{ top: "38%", left: "58%" },
|
|
55
|
+
{ top: "62%", left: "10%" },
|
|
56
|
+
{ top: "62%", left: "48%" },
|
|
57
|
+
{ top: "80%", left: "22%" },
|
|
58
|
+
{ top: "80%", left: "60%" },
|
|
59
|
+
{ top: "50%", left: "34%" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const pick = <T,>(items: T[]): T => items[Math.floor(Math.random() * items.length)] ?? items[0]!;
|
|
63
|
+
|
|
64
|
+
export function FilmWatermark({
|
|
65
|
+
name,
|
|
66
|
+
email,
|
|
67
|
+
rentalRef,
|
|
68
|
+
intervalMs = 45_000,
|
|
69
|
+
visibleMs = 6_000,
|
|
70
|
+
opacity = 0.42,
|
|
71
|
+
className,
|
|
72
|
+
style,
|
|
73
|
+
}: FilmWatermarkProps) {
|
|
74
|
+
const [visible, setVisible] = useState(false);
|
|
75
|
+
const [placement, setPlacement] = useState(() => PLACEMENTS[0]!);
|
|
76
|
+
/** Bumped by the tamper check to force React to re-apply the style it owns. */
|
|
77
|
+
const [assertion, setAssertion] = useState(0);
|
|
78
|
+
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
79
|
+
|
|
80
|
+
// Identify with whatever we were given. An empty overlay is worse than none:
|
|
81
|
+
// it draws attention to the mechanism while carrying no evidence.
|
|
82
|
+
const label = useMemo(
|
|
83
|
+
() =>
|
|
84
|
+
[name, email, rentalRef]
|
|
85
|
+
.map((part) => (part ?? "").trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(" · "),
|
|
88
|
+
[name, email, rentalRef],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// The show/hide cycle. Jittered so the interval itself is not a pattern
|
|
92
|
+
// somebody can cut around.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!label) return;
|
|
95
|
+
let showTimer: ReturnType<typeof setTimeout>;
|
|
96
|
+
let hideTimer: ReturnType<typeof setTimeout>;
|
|
97
|
+
|
|
98
|
+
const cycle = () => {
|
|
99
|
+
setPlacement(pick(PLACEMENTS));
|
|
100
|
+
setVisible(true);
|
|
101
|
+
hideTimer = setTimeout(() => {
|
|
102
|
+
setVisible(false);
|
|
103
|
+
// 0.6x to 1.4x of the nominal interval.
|
|
104
|
+
showTimer = setTimeout(cycle, intervalMs * (0.6 + Math.random() * 0.8));
|
|
105
|
+
}, visibleMs);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// First appearance lands early, so a viewer who stops recording after 30
|
|
109
|
+
// seconds still has it, then settles into the cycle.
|
|
110
|
+
showTimer = setTimeout(cycle, 3_000 + Math.random() * 4_000);
|
|
111
|
+
return () => {
|
|
112
|
+
clearTimeout(showTimer);
|
|
113
|
+
clearTimeout(hideTimer);
|
|
114
|
+
};
|
|
115
|
+
}, [label, intervalMs, visibleMs]);
|
|
116
|
+
|
|
117
|
+
// The tamper check. Watches the node's own attributes and its parent's child
|
|
118
|
+
// list, and re-asserts by bumping state - React then rewrites the style it
|
|
119
|
+
// owns, whatever was done to it.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!label || typeof MutationObserver === "undefined") return;
|
|
122
|
+
const node = nodeRef.current;
|
|
123
|
+
const parent = node?.parentElement;
|
|
124
|
+
if (!node || !parent) return;
|
|
125
|
+
|
|
126
|
+
const observer = new MutationObserver((records) => {
|
|
127
|
+
const touched = records.some(
|
|
128
|
+
(record) =>
|
|
129
|
+
(record.type === "attributes" && record.target === node) ||
|
|
130
|
+
(record.type === "childList" && [...record.removedNodes].includes(node)),
|
|
131
|
+
);
|
|
132
|
+
if (touched) setAssertion((n) => n + 1);
|
|
133
|
+
});
|
|
134
|
+
observer.observe(parent, { childList: true });
|
|
135
|
+
observer.observe(node, { attributes: true, attributeFilter: ["style", "class", "hidden"] });
|
|
136
|
+
return () => observer.disconnect();
|
|
137
|
+
}, [label, assertion]);
|
|
138
|
+
|
|
139
|
+
if (!label) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
ref={nodeRef}
|
|
144
|
+
key={assertion}
|
|
145
|
+
aria-hidden="true"
|
|
146
|
+
data-testid="film-watermark"
|
|
147
|
+
style={{
|
|
148
|
+
position: "absolute",
|
|
149
|
+
top: placement.top,
|
|
150
|
+
left: placement.left,
|
|
151
|
+
maxWidth: "70%",
|
|
152
|
+
// Never intercepts a click: the play/pause surface underneath has to
|
|
153
|
+
// keep working, and a viewer fighting an invisible box is a support
|
|
154
|
+
// ticket that reads as "the player is broken".
|
|
155
|
+
pointerEvents: "none",
|
|
156
|
+
userSelect: "none",
|
|
157
|
+
zIndex: 20,
|
|
158
|
+
opacity: visible ? opacity : 0,
|
|
159
|
+
transition: "opacity 900ms ease-in-out",
|
|
160
|
+
// Deliberate literals: this sits over video frames of unknown colour, so
|
|
161
|
+
// it cannot be a theme token. White text with a dark shadow is the only
|
|
162
|
+
// pair that stays readable over both a night scene and a snowfield.
|
|
163
|
+
color: "#ffffff",
|
|
164
|
+
textShadow: "0 1px 3px rgba(0,0,0,0.85)",
|
|
165
|
+
fontSize: "clamp(10px, 1.4vw, 14px)",
|
|
166
|
+
letterSpacing: "0.02em",
|
|
167
|
+
lineHeight: 1.4,
|
|
168
|
+
whiteSpace: "nowrap",
|
|
169
|
+
overflow: "hidden",
|
|
170
|
+
textOverflow: "ellipsis",
|
|
171
|
+
...style,
|
|
172
|
+
}}
|
|
173
|
+
className={className}
|
|
174
|
+
>
|
|
175
|
+
{label}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default FilmWatermark;
|