@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
|
@@ -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>;
|