@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.
Files changed (40) hide show
  1. package/package.json +3 -2
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/data/queries/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
  5. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  6. package/src/data/queries/useFilms.ts +561 -0
  7. package/src/data/queries/usePaymentFlow.ts +28 -3
  8. package/src/i18n/de.json +130 -28
  9. package/src/i18n/en.json +130 -28
  10. package/src/index.ts +5 -0
  11. package/src/provider/ForgeAppProvider.tsx +10 -0
  12. package/src/provider/ForgeProvider.tsx +18 -3
  13. package/src/server/index.ts +76 -17
  14. package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
  15. package/src/ui/headless/dialog.tsx +139 -4
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +21 -16
  26. package/src/ui/media/CallStage.tsx +59 -1
  27. package/src/ui/media/CallWindowNotice.tsx +94 -0
  28. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  29. package/src/ui/media/index.ts +9 -0
  30. package/src/ui/styled/AccountDashboard.tsx +101 -37
  31. package/src/ui/styled/FilmCatalog.tsx +278 -0
  32. package/src/ui/styled/FilmDetail.tsx +661 -0
  33. package/src/ui/styled/FilmLibrary.tsx +291 -0
  34. package/src/ui/styled/FilmWatch.tsx +701 -0
  35. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  36. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
  37. package/src/ui/styled/forge-utilities.css +6 -0
  38. package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
  39. package/src/utils/paystackCheckout.ts +47 -0
  40. package/src/utils/structuredData.ts +88 -17
@@ -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>(apiUrl: string, path: string, params?: Record<string, string | undefined>): Promise<T | null> {
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: { apiUrl: string; profileId?: string; idOrSlug: string }): Promise<IEvent | null> {
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 (await getJson<PodcastShow[]>(opts.apiUrl, `/public/blog/posts/podcasts`, { profileId: opts.profileId })) ?? [];
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.apiUrl,
464
- `/public/collections/${encodeURIComponent(opts.slug)}/query`,
465
- { profileId: opts.profileId, scope, query },
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 re-export of Radix Dialog so the headless
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
- export {
4
- Root as DialogRoot,
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 as DialogContent,
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;