@tribe-nest/forge 3.34.0 → 3.35.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 (33) 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/useFilmPlaybackSession.ts +224 -0
  5. package/src/data/queries/useFilms.ts +561 -0
  6. package/src/i18n/de.json +128 -28
  7. package/src/i18n/en.json +128 -28
  8. package/src/index.ts +5 -0
  9. package/src/provider/ForgeAppProvider.tsx +10 -0
  10. package/src/provider/ForgeProvider.tsx +18 -3
  11. package/src/server/index.ts +76 -17
  12. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  13. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  14. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  15. package/src/ui/headless/film/index.ts +36 -0
  16. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  17. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  18. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  19. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  20. package/src/ui/headless/index.ts +6 -9
  21. package/src/ui/index.ts +18 -16
  22. package/src/ui/media/CallStage.tsx +59 -1
  23. package/src/ui/media/CallWindowNotice.tsx +94 -0
  24. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  25. package/src/ui/media/index.ts +9 -0
  26. package/src/ui/styled/AccountDashboard.tsx +28 -36
  27. package/src/ui/styled/FilmCatalog.tsx +278 -0
  28. package/src/ui/styled/FilmDetail.tsx +661 -0
  29. package/src/ui/styled/FilmLibrary.tsx +254 -0
  30. package/src/ui/styled/FilmWatch.tsx +701 -0
  31. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  32. package/src/ui/styled/forge-utilities.css +3 -0
  33. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,167 @@
1
+ // @vitest-environment jsdom
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { useStageFullscreen } from "../useStageFullscreen";
6
+
7
+ /**
8
+ * The regression this file exists for.
9
+ *
10
+ * The watermark is drawn INSIDE the stage. The browser promotes exactly one
11
+ * element into the fullscreen layer and hides the rest of the document, so when
12
+ * the native control bar fullscreened the `<video>`, the watermark stopped
13
+ * rendering for the whole session - and fullscreen is how a film is actually
14
+ * watched. Every test here is really asking one question: does the element that
15
+ * ends up fullscreen still CONTAIN the watermark?
16
+ */
17
+ describe("useStageFullscreen", () => {
18
+ let stage: HTMLDivElement;
19
+ let video: HTMLVideoElement;
20
+ let fullscreenElement: Element | null;
21
+ let requestCalls: Element[];
22
+
23
+ const fireChange = () => document.dispatchEvent(new Event("fullscreenchange"));
24
+
25
+ beforeEach(() => {
26
+ Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
27
+ requestCalls = [];
28
+ fullscreenElement = null;
29
+
30
+ stage = document.createElement("div");
31
+ video = document.createElement("video");
32
+ // The real shape: the video (and the watermark) are children of the stage.
33
+ stage.appendChild(video);
34
+ document.body.appendChild(stage);
35
+
36
+ Object.defineProperty(document, "fullscreenElement", {
37
+ configurable: true,
38
+ get: () => fullscreenElement,
39
+ });
40
+ document.exitFullscreen = vi.fn(async () => {
41
+ fullscreenElement = null;
42
+ });
43
+ Element.prototype.requestFullscreen = vi.fn(async function (this: Element) {
44
+ requestCalls.push(this);
45
+ fullscreenElement = this;
46
+ });
47
+ });
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = "";
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ const mount = () => {
55
+ const hook = renderHook(() => useStageFullscreen());
56
+ act(() => {
57
+ hook.result.current.ref.current = stage;
58
+ });
59
+ return hook;
60
+ };
61
+
62
+ it("fullscreens the STAGE, so the watermark is inside the promoted element", async () => {
63
+ const { result } = mount();
64
+
65
+ await act(async () => {
66
+ result.current.toggle();
67
+ });
68
+
69
+ expect(requestCalls).toEqual([stage]);
70
+ // The whole point: whatever went fullscreen still contains the video, and
71
+ // therefore still contains the watermark drawn beside it.
72
+ expect(fullscreenElement).toBe(stage);
73
+ expect((fullscreenElement as HTMLElement).contains(video)).toBe(true);
74
+ });
75
+
76
+ it("swaps a native VIDEO fullscreen for the stage", async () => {
77
+ const { result } = mount();
78
+
79
+ // What Firefox and Safari do when the fan uses the native control bar: they
80
+ // ignore `controlsList="nofullscreen"` and promote the video itself.
81
+ await act(async () => {
82
+ fullscreenElement = video;
83
+ fireChange();
84
+ });
85
+ await act(async () => {
86
+ fireChange();
87
+ });
88
+
89
+ expect(document.exitFullscreen).toHaveBeenCalled();
90
+ expect(requestCalls).toContain(stage);
91
+ });
92
+
93
+ it("does not fight a browser that refuses the swap", async () => {
94
+ (Element.prototype.requestFullscreen as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("gesture required"));
95
+ const { result } = mount();
96
+ expect(result.current).toBeTruthy();
97
+
98
+ await act(async () => {
99
+ fullscreenElement = video;
100
+ fireChange();
101
+ });
102
+ await act(async () => {
103
+ fireChange();
104
+ fireChange();
105
+ });
106
+
107
+ // One correction attempt per transition. A refusal read as another stray
108
+ // transition would ping-pong the fan in and out of fullscreen for ever.
109
+ expect((Element.prototype.requestFullscreen as ReturnType<typeof vi.fn>).mock.calls.length).toBeLessThanOrEqual(2);
110
+ });
111
+
112
+ it("reports fullscreen only when the STAGE is the promoted element", async () => {
113
+ const { result } = mount();
114
+
115
+ await act(async () => {
116
+ fullscreenElement = video;
117
+ fireChange();
118
+ });
119
+ // A video-level fullscreen is NOT the stage being fullscreen: the stage must
120
+ // keep its 16/9 box rather than stretch behind a layer it is not in.
121
+ expect(result.current.isFullscreen).toBe(false);
122
+
123
+ await act(async () => {
124
+ fullscreenElement = stage;
125
+ fireChange();
126
+ });
127
+ expect(result.current.isFullscreen).toBe(true);
128
+ });
129
+
130
+ it("reports support on the FIRST render, before the stage exists", () => {
131
+ // The regression. `<FilmWatch>` returns a loading panel first, so the stage
132
+ // is not in the DOM when the hook mounts. Probing `ref.current` there latched
133
+ // "unsupported" for ever, which hid Forge's fullscreen button while the
134
+ // player had already dropped the native one - desktop viewers ended up with
135
+ // no way into fullscreen at all.
136
+ Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
137
+
138
+ const { result } = renderHook(() => useStageFullscreen());
139
+ // Deliberately NO ref attached: this is the loading render.
140
+ expect(result.current.ref.current).toBeNull();
141
+ expect(result.current.isSupported).toBe(true);
142
+ });
143
+
144
+ it("reports no support when the document forbids fullscreen", () => {
145
+ // An iframe without `allowfullscreen`. Hiding the button is right here, and
146
+ // the element probe could not see this at all.
147
+ Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: false });
148
+
149
+ const { result } = renderHook(() => useStageFullscreen());
150
+ expect(result.current.isSupported).toBe(false);
151
+ });
152
+
153
+ it("exits rather than re-requesting when already fullscreen", async () => {
154
+ const { result } = mount();
155
+ await act(async () => {
156
+ result.current.toggle();
157
+ });
158
+ requestCalls.length = 0;
159
+
160
+ await act(async () => {
161
+ result.current.toggle();
162
+ });
163
+
164
+ expect(document.exitFullscreen).toHaveBeenCalled();
165
+ expect(requestCalls).toEqual([]);
166
+ });
167
+ });
@@ -0,0 +1,36 @@
1
+ // Films and series (Tier-1 headless).
2
+ //
3
+ // This tier is the point of the domain, not an afterthought: a creator who wants
4
+ // a fully custom viewing experience builds it from here rather than pointing
5
+ // their own `<video>` at a URL they found. Read `useFilmPlayback`'s docblock
6
+ // before doing so - it says which protection layer a custom player gives up
7
+ // (the viewer overlay, and only that) and what keeping it costs (one component,
8
+ // `<FilmWatermark>`).
9
+
10
+ export {
11
+ useFilmPlayback,
12
+ buildFilmPlaybackGate,
13
+ filmSignInHref,
14
+ msUntil,
15
+ type UseFilmPlaybackOptions,
16
+ type FilmPlaybackGate,
17
+ type FilmPlaybackGateKind,
18
+ type FilmPlaybackState,
19
+ type FilmPlaybackSubtitle,
20
+ } from "./useFilmPlayback";
21
+
22
+ export { useFilmCatalog, type UseFilmCatalogOptions, type FilmCatalogState } from "./useFilmCatalog";
23
+
24
+ export {
25
+ useFilmRentalFlow,
26
+ type UseFilmRentalFlowOptions,
27
+ type FilmRentalStep,
28
+ type FilmRentalFlowState,
29
+ } from "./useFilmRentalFlow";
30
+
31
+ export { FilmWatermark, type FilmWatermarkProps } from "./FilmWatermark";
32
+ // Container fullscreen. A custom player MUST use this rather than letting the
33
+ // native control bar fullscreen its <video>: the browser promotes one element
34
+ // and hides the rest of the document, so a video-level fullscreen leaves the
35
+ // watermark behind for the whole session.
36
+ export { useStageFullscreen, type StageFullscreen } from "./useStageFullscreen";
@@ -0,0 +1,66 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useFilms, type FilmKind, type GetFilmsParams, type PublicFilmSummary } from "../../../data/queries/useFilms";
3
+
4
+ export interface UseFilmCatalogOptions extends GetFilmsParams {
5
+ /** Rows per page. The API caps this at 50. */
6
+ limit?: number;
7
+ }
8
+
9
+ /**
10
+ * Headless film/series catalogue: paging, a kind filter and a search box over
11
+ * `useFilms`. Bring your own markup.
12
+ *
13
+ * The one thing worth reading before using it: `enabled` is not the same
14
+ * question as "are there films". With the film-rentals switch off the API
15
+ * answers 200 with an empty list and `enabled: false`, because `/i/films` ships
16
+ * to every site that takes a starter update and cannot itself be gated. A page
17
+ * that draws "no films yet" for both is telling a creator who has not turned the
18
+ * feature on that their catalogue is empty, which sends them looking in the
19
+ * wrong place.
20
+ */
21
+ export function useFilmCatalog(opts: UseFilmCatalogOptions = {}) {
22
+ const [page, setPage] = useState(opts.page ?? 1);
23
+ const [query, setQuery] = useState(opts.query ?? "");
24
+ const [kind, setKind] = useState<FilmKind | undefined>(opts.kind);
25
+
26
+ const limit = opts.limit ?? 20;
27
+ const { data, isLoading, isError, refetch } = useFilms({ page, limit, kind, query: query.trim() || undefined });
28
+
29
+ const films: PublicFilmSummary[] = data?.data ?? [];
30
+ const total = data?.total ?? 0;
31
+ const pageCount = Math.max(1, Math.ceil(total / limit));
32
+
33
+ return {
34
+ films,
35
+ total,
36
+ page,
37
+ limit,
38
+ pageCount,
39
+ hasNextPage: page < pageCount,
40
+ hasPrevPage: page > 1,
41
+ /** Paging that also resets nothing else - the filters are the caller's to keep. */
42
+ nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
43
+ prevPage: () => setPage((p) => Math.max(1, p - 1)),
44
+ setPage,
45
+ query,
46
+ /** Changing the search resets to page 1; staying on page 4 of a new search shows nothing. */
47
+ setQuery: (next: string) => {
48
+ setQuery(next);
49
+ setPage(1);
50
+ },
51
+ kind,
52
+ setKind: (next: FilmKind | undefined) => {
53
+ setKind(next);
54
+ setPage(1);
55
+ },
56
+ /** Is the film-rentals feature switched on for this tenant? See the docblock. */
57
+ enabled: data?.enabled ?? false,
58
+ /** Nothing to show, and the feature IS on - a genuinely empty catalogue. */
59
+ isEmpty: useMemo(() => !isLoading && (data?.enabled ?? false) && films.length === 0, [isLoading, data, films]),
60
+ isLoading,
61
+ isError,
62
+ refetch,
63
+ };
64
+ }
65
+
66
+ export type FilmCatalogState = ReturnType<typeof useFilmCatalog>;