@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
@@ -0,0 +1,277 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
4
+ import {
5
+ cheapestFilmRentalOption,
6
+ filmRentalOptions,
7
+ useCreateFilmRental,
8
+ useFilmRentalFinalize,
9
+ type PublicFilmDetail,
10
+ type PublicFilmRental,
11
+ type PublicFilmRentalOption,
12
+ } from "../../../data/queries/useFilms";
13
+ import { filmErrorMessage, filmErrorStatus } from "../../../data/queries/useFilmPlaybackSession";
14
+ import { filmSignInHref } from "./useFilmPlayback";
15
+
16
+ /**
17
+ * Headless rent-a-title flow: pick a window, create the pending rental, charge
18
+ * for it, and reconcile it when the provider sends the fan back.
19
+ *
20
+ * A rental is always the WHOLE title. What IS chosen is the window: a title can
21
+ * be sold at 8 hours for less and 48 hours for more, so the flow carries a
22
+ * selection, defaulted to the cheapest option and settable with
23
+ * `selectOption(id)`.
24
+ *
25
+ * Mirrors the events pillar (create -> start-payment -> finalize) with no hold
26
+ * machinery, because a rental is a licence to stream and nothing is scarce.
27
+ *
28
+ * Three things this hook refuses to do, all on purpose:
29
+ *
30
+ * - It never sends a price or a duration. It sends the OPTION ID, and the
31
+ * server resolves both numbers from that row; the body is strict, so a
32
+ * client that sends a figure gets a 400 rather than a quietly ignored field.
33
+ * That is the fix for the client-set-price defect the 2026-06-10 audit found.
34
+ * `options` carries the same numbers purely so the button can state what is
35
+ * being bought before the fan pays.
36
+ * - It never renders a window the fan has not been shown. The selection is
37
+ * always one of `options`, and `selectedOption` is what the CTA must label
38
+ * itself from: a fan who buys a ten-episode series on a 48-hour clock and
39
+ * discovers it at expiry has been mis-sold.
40
+ * - It never treats a rental as bought until `finalize` says so. The return leg
41
+ * and the provider webhook race, and finalize is the one idempotent place
42
+ * that flips the row exactly once whichever wins.
43
+ */
44
+
45
+ export type FilmRentalStep =
46
+ /** Nothing started. */
47
+ | "browse"
48
+ /** A pending rental exists and Stripe needs card details in place. */
49
+ | "payment"
50
+ /** Paid and reconciled. */
51
+ | "rented";
52
+
53
+ export interface UseFilmRentalFlowOptions {
54
+ /** The title being rented. The flow is inert until this arrives. */
55
+ film?: PublicFilmDetail | null;
56
+ /** Where the provider sends the fan back. Default `/i/films/:slug?rental=:rentalId`. */
57
+ returnPath?: (slug: string, rentalId: string) => string;
58
+ /**
59
+ * A rental id read off the return URL. When present the flow finalizes it on
60
+ * mount, which is what turns a provider redirect back into "you own this".
61
+ */
62
+ finalizeRentalId?: string;
63
+ /** Sign-in path (default `/login`; the site-starter passes `/i/login`). */
64
+ loginPath?: string;
65
+ /** Called once a rental is confirmed active, free or paid. */
66
+ onRented?: (rental: PublicFilmRental) => void;
67
+ }
68
+
69
+ const defaultReturnPath = (slug: string, rentalId: string) => `/i/films/${slug}?rental=${rentalId}`;
70
+
71
+ export function useFilmRentalFlow(opts: UseFilmRentalFlowOptions) {
72
+ const { film, finalizeRentalId } = opts;
73
+ const { isAuthenticated, isInitialized } = usePublicAuth();
74
+ const filmId = film?.id;
75
+
76
+ const [step, setStep] = useState<FilmRentalStep>("browse");
77
+ const [rental, setRental] = useState<PublicFilmRental | null>(null);
78
+ const [error, setError] = useState<string | null>(null);
79
+ /**
80
+ * Set the moment a rental is created and cleared by the effect that starts the
81
+ * charge. It exists because `usePaymentFlow` captures its `path` at render
82
+ * time, and the rental id is IN that path - calling `start()` in the same tick
83
+ * as `setRental` would post to the previous render's URL.
84
+ */
85
+ const [awaitingCharge, setAwaitingCharge] = useState(false);
86
+ /**
87
+ * The window the fan has picked, or null while they have not picked one.
88
+ *
89
+ * Null is not "none": it resolves to the CHEAPEST option below. A title with a
90
+ * single window therefore needs no picker at all and `rent()` still knows what
91
+ * it is buying, and a title with several defaults to the cheap one rather than
92
+ * to whichever the creator happened to create first.
93
+ */
94
+ const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
95
+ const onRentedRef = useRef(opts.onRented);
96
+ onRentedRef.current = opts.onRented;
97
+
98
+ const createRental = useCreateFilmRental(filmId);
99
+
100
+ const returnPath = opts.returnPath ?? defaultReturnPath;
101
+ const returnUrl = useMemo(() => {
102
+ if (!film || !rental) return undefined;
103
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
104
+ return `${origin}${returnPath(film.slug, rental.id)}`;
105
+ }, [film, rental, returnPath]);
106
+
107
+ const flow = usePaymentFlow({
108
+ path: filmId && rental ? `/public/films/${filmId}/rentals/${rental.id}/start-payment` : undefined,
109
+ autoStart: false,
110
+ returnUrl,
111
+ });
112
+
113
+ // Start the charge one render after the rental lands, so the path above is the
114
+ // new rental's. See the note on `awaitingCharge`.
115
+ useEffect(() => {
116
+ if (!awaitingCharge || !rental || !returnUrl) return;
117
+ if (rental.status !== "pending_payment") return;
118
+ setAwaitingCharge(false);
119
+ flow
120
+ .start({ returnUrl })
121
+ .then((result) => {
122
+ // Paystack drives its own modal and navigates to `returnUrl` itself.
123
+ // Only Stripe needs a card form drawn on this page.
124
+ if (!result.provider || result.provider === "stripe") setStep("payment");
125
+ })
126
+ .catch((e) => setError(filmErrorMessage(e) ?? "This rental could not be started."));
127
+ // `flow` is rebuilt each render; depending on it would re-fire the charge.
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [awaitingCharge, rental, returnUrl]);
130
+
131
+ // ── The return leg ─────────────────────────────────────────────────────────
132
+ const finalize = useFilmRentalFinalize(filmId, finalizeRentalId);
133
+ useEffect(() => {
134
+ if (!finalize.data) return;
135
+ setRental(finalize.data);
136
+ if (finalize.data.status === "active") {
137
+ setStep("rented");
138
+ onRentedRef.current?.(finalize.data);
139
+ }
140
+ }, [finalize.data]);
141
+
142
+ /**
143
+ * The windows on sale, in the order the creator arranged them. Empty means the
144
+ * title is not on sale, which keeps the button off the page rather than
145
+ * putting a 400 behind it.
146
+ */
147
+ const options = useMemo<PublicFilmRentalOption[]>(() => filmRentalOptions(film), [film]);
148
+
149
+ /**
150
+ * The window that would be bought right now.
151
+ *
152
+ * Resolved rather than stored, so a selection that no longer exists (the
153
+ * creator archived that window while the page was open) falls back to the
154
+ * cheapest live one instead of sending an id the checkout would 404.
155
+ *
156
+ * ⚠️ The fallback is the CHEAPEST window, computed as such, not `options[0]`.
157
+ * That list is now ordered by the creator's hand, so its first row is whatever
158
+ * they chose to show first, and defaulting to it would pre-select a window
159
+ * that costs more than another on the same title.
160
+ */
161
+ const selectedOption = useMemo<PublicFilmRentalOption | null>(
162
+ () => options.find((option) => option.id === selectedOptionId) ?? cheapestFilmRentalOption(film) ?? null,
163
+ [options, selectedOptionId, film],
164
+ );
165
+
166
+ /**
167
+ * Buy it, in one window.
168
+ *
169
+ * Takes the option id explicitly so a surface can rent straight off a row
170
+ * without a round trip through the selection; falls back to `selectedOption`,
171
+ * which is what a single-window title and a picker-driven CTA both use.
172
+ *
173
+ * A price of 0 comes back already active with the receipt sent, so the free
174
+ * path never touches a payment provider. Everything else lands on
175
+ * `pending_payment` and the effect above charges it.
176
+ */
177
+ const rent = useCallback(
178
+ async (rentalOptionId?: string) => {
179
+ setError(null);
180
+ if (!filmId) return;
181
+ // Nothing on sale, or an id for a window this title does not have. Either
182
+ // way there is no honest charge to mint, and the server would 404.
183
+ const optionId = rentalOptionId ?? selectedOption?.id;
184
+ if (!optionId) return;
185
+ if (!isAuthenticated) {
186
+ // Renting is account-bound from day one, so there is no guest path to
187
+ // fall back to. Send them to sign in and bring them back HERE.
188
+ if (typeof window !== "undefined") {
189
+ const here = `${window.location.pathname}${window.location.search}`;
190
+ window.location.assign(filmSignInHref(opts.loginPath ?? "/login", here));
191
+ }
192
+ return;
193
+ }
194
+ try {
195
+ const created = await createRental.mutateAsync({ rentalOptionId: optionId });
196
+ setRental(created);
197
+ if (created.status === "active") {
198
+ setStep("rented");
199
+ onRentedRef.current?.(created);
200
+ return;
201
+ }
202
+ setAwaitingCharge(true);
203
+ } catch (e) {
204
+ const status = filmErrorStatus(e);
205
+ setError(
206
+ filmErrorMessage(e) ??
207
+ (status === 404 ? "This title is no longer available." : "This rental could not be created."),
208
+ );
209
+ }
210
+ },
211
+ // `createRental` is a stable react-query mutation object.
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ [filmId, isAuthenticated, opts.loginPath, selectedOption?.id],
214
+ );
215
+
216
+ /**
217
+ * Deliberately NOT memoized: it closes over `flow`, which is rebuilt every
218
+ * render, and a `useCallback([])` here would go on resetting the very first
219
+ * render's charge for ever.
220
+ */
221
+ const reset = () => {
222
+ setStep("browse");
223
+ setRental(null);
224
+ setError(null);
225
+ setAwaitingCharge(false);
226
+ flow.reset();
227
+ };
228
+
229
+ return {
230
+ step,
231
+ setStep,
232
+ /** The rental in flight, or the one just finalized. */
233
+ rental,
234
+ /**
235
+ * The rental to reason about: whatever just settled in this session, falling
236
+ * back to whatever the title payload arrived with.
237
+ *
238
+ * One row is the whole answer. It drives the countdown AND every per-episode
239
+ * affordance, because a rental on this title covers every episode of it.
240
+ */
241
+ viewerRental: rental?.status === "active" ? rental : (film?.viewerRental ?? null),
242
+ /** Every window on sale, cheapest first. Empty means nothing is on sale. */
243
+ options,
244
+ /** The window `rent()` would buy. Never an id the title does not carry. */
245
+ selectedOption,
246
+ /**
247
+ * Pick a window.
248
+ *
249
+ * Only worth drawing when there is more than one: a picker offering a choice
250
+ * of one is a control that cannot be used, and it makes a simple title read
251
+ * as a complicated one.
252
+ */
253
+ selectOption: (rentalOptionId: string) => setSelectedOptionId(rentalOptionId),
254
+ rent,
255
+ reset,
256
+ error,
257
+ isAuthenticated,
258
+ isInitialized,
259
+ isCreating: createRental.isPending,
260
+ isStartingPayment: flow.isStarting,
261
+ isFinalizing: finalize.isLoading,
262
+ /** STRIPE: the PaymentIntent client secret for the in-page card form. */
263
+ clientSecret: flow.clientSecret,
264
+ /** PAYSTACK: the started inline session and the way back into it after a dismissal. */
265
+ isPaystack: flow.isPaystack,
266
+ canOpenPaystack: flow.canOpenPaystack,
267
+ openPaystackCheckout: flow.openPaystackCheckout,
268
+ returnUrl,
269
+ /** The charged amount in MAJOR units, from the provider quote. */
270
+ chargeAmount: flow.result?.amount,
271
+ chargeCurrency: flow.result?.currency,
272
+ /** Authoritative sales-tax quote from start-payment, in MAJOR units. Display only. */
273
+ taxQuote: flow.result?.taxQuote ?? null,
274
+ };
275
+ }
276
+
277
+ export type FilmRentalFlowState = ReturnType<typeof useFilmRentalFlow>;
@@ -0,0 +1,156 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Fullscreen the STAGE, never the `<video>`.
5
+ *
6
+ * ## The bug this exists to close
7
+ *
8
+ * The browser puts exactly ONE element into the fullscreen layer, and the native
9
+ * control bar on a `<video>` fullscreens the video element itself. Everything
10
+ * else in the document, including a watermark drawn as a sibling over the
11
+ * stage, simply stops rendering. Since fullscreen is how people actually watch a
12
+ * film, that meant the renter-identifying overlay was absent for most of the
13
+ * viewing time, and a recording made in fullscreen carried no identity at all.
14
+ *
15
+ * Fullscreening the stage instead keeps the watermark in the layer, because it
16
+ * is a DESCENDANT of the element being promoted rather than a sibling of it.
17
+ *
18
+ * ## Why both a button and a correction
19
+ *
20
+ * `controlsList="nofullscreen"` hides the native button on Chromium (desktop and
21
+ * Android), so there the stage button is the only route and nothing has to be
22
+ * corrected. Firefox and Safari ignore `controlsList`, so their native button is
23
+ * still live and still targets the video. The `fullscreenchange` listener is the
24
+ * net under that: when the element that went fullscreen is a descendant of the
25
+ * stage rather than the stage itself, it swaps them.
26
+ *
27
+ * The swap is deliberately not attempted more than once per transition
28
+ * (`correctingRef`). A browser that refuses the re-request would otherwise have
29
+ * its refusal read as another stray transition, and the two would ping-pong.
30
+ *
31
+ * ## iPhone is out of reach, on purpose
32
+ *
33
+ * iOS Safari hands `<video>` fullscreen to a native OS-level player that no DOM
34
+ * overlay can enter, and it exposes `webkitEnterFullscreen` rather than the
35
+ * Fullscreen API on the element. There is no correction available: the watermark
36
+ * is simply absent there. `playsInline` (set by the player) is what stops iOS
37
+ * forcing that view on every play. The burned-in transcoder watermark still ties
38
+ * a leak to the film, just not to the renter, and that limit is documented
39
+ * rather than papered over.
40
+ */
41
+ export type StageFullscreen = {
42
+ /** Attach to the element that should fill the screen (the one wrapping player + watermark). */
43
+ ref: React.RefObject<HTMLDivElement | null>;
44
+ isFullscreen: boolean;
45
+ /** True when the browser exposes the Fullscreen API at all. */
46
+ isSupported: boolean;
47
+ toggle: () => void;
48
+ };
49
+
50
+ type FullscreenCapableElement = HTMLDivElement & {
51
+ webkitRequestFullscreen?: () => Promise<void> | void;
52
+ };
53
+
54
+ type FullscreenCapableDocument = Document & {
55
+ webkitFullscreenElement?: Element | null;
56
+ webkitExitFullscreen?: () => Promise<void> | void;
57
+ webkitFullscreenEnabled?: boolean;
58
+ };
59
+
60
+ /**
61
+ * Asked of the DOCUMENT, never of the stage element.
62
+ *
63
+ * ⚠️ This is the shape of a bug that shipped: support was probed from
64
+ * `ref.current` in a mount effect, but `<FilmWatch>` returns a loading panel on
65
+ * its first render, so the stage did not exist yet. The probe saw `null`, latched
66
+ * "unsupported", and never re-ran - which hid Forge's own fullscreen button while
67
+ * the player had ALREADY removed the native one via `controlsList`, leaving
68
+ * desktop viewers with no way into fullscreen at all.
69
+ *
70
+ * `document.fullscreenEnabled` needs no element, is stable from first render, and
71
+ * answers the question that actually matters: is fullscreen permitted here. It is
72
+ * correctly false inside an iframe without `allowfullscreen`, which is a case the
73
+ * element probe got wrong in the other direction.
74
+ */
75
+ function fullscreenPermitted(): boolean {
76
+ if (typeof document === "undefined") return false;
77
+ const doc = document as FullscreenCapableDocument;
78
+ return Boolean(doc.fullscreenEnabled ?? doc.webkitFullscreenEnabled ?? false);
79
+ }
80
+
81
+ function currentFullscreenElement(): Element | null {
82
+ if (typeof document === "undefined") return null;
83
+ const doc = document as FullscreenCapableDocument;
84
+ return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
85
+ }
86
+
87
+ async function exitFullscreen(): Promise<void> {
88
+ const doc = document as FullscreenCapableDocument;
89
+ if (doc.exitFullscreen) return void (await doc.exitFullscreen());
90
+ if (doc.webkitExitFullscreen) return void (await doc.webkitExitFullscreen());
91
+ }
92
+
93
+ async function requestFullscreen(element: FullscreenCapableElement): Promise<void> {
94
+ if (element.requestFullscreen) return void (await element.requestFullscreen());
95
+ if (element.webkitRequestFullscreen) return void (await element.webkitRequestFullscreen());
96
+ }
97
+
98
+ export function useStageFullscreen(): StageFullscreen {
99
+ const ref = useRef<HTMLDivElement | null>(null);
100
+ const [isFullscreen, setIsFullscreen] = useState(false);
101
+ // Initialised from the document so the very first render is already correct:
102
+ // the button must be able to appear the moment the stage does.
103
+ const [isSupported, setIsSupported] = useState(fullscreenPermitted);
104
+ const correctingRef = useRef(false);
105
+
106
+ useEffect(() => {
107
+ if (typeof document === "undefined") return;
108
+ setIsSupported(fullscreenPermitted());
109
+
110
+ const onChange = () => {
111
+ const active = currentFullscreenElement();
112
+ const stage = ref.current;
113
+ setIsFullscreen(Boolean(active) && active === stage);
114
+
115
+ if (!stage || !active || active === stage) {
116
+ correctingRef.current = false;
117
+ return;
118
+ }
119
+ // Something inside the stage went fullscreen on its own: the native
120
+ // control bar on the <video>. Swap it for the stage so the watermark
121
+ // travels with it.
122
+ if (!stage.contains(active) || correctingRef.current) return;
123
+
124
+ correctingRef.current = true;
125
+ void exitFullscreen()
126
+ .then(() => requestFullscreen(stage as FullscreenCapableElement))
127
+ .catch(() => {
128
+ // A browser may refuse the re-request without a fresh user gesture.
129
+ // Losing fullscreen is better than looping, and the fan can press the
130
+ // stage button, which always carries its own gesture.
131
+ })
132
+ .finally(() => {
133
+ correctingRef.current = false;
134
+ });
135
+ };
136
+
137
+ document.addEventListener("fullscreenchange", onChange);
138
+ document.addEventListener("webkitfullscreenchange", onChange);
139
+ return () => {
140
+ document.removeEventListener("fullscreenchange", onChange);
141
+ document.removeEventListener("webkitfullscreenchange", onChange);
142
+ };
143
+ }, []);
144
+
145
+ const toggle = useCallback(() => {
146
+ const stage = ref.current as FullscreenCapableElement | null;
147
+ if (!stage) return;
148
+ if (currentFullscreenElement()) {
149
+ void exitFullscreen().catch(() => undefined);
150
+ return;
151
+ }
152
+ void requestFullscreen(stage).catch(() => undefined);
153
+ }, []);
154
+
155
+ return { ref, isFullscreen, isSupported, toggle };
156
+ }
@@ -5,10 +5,7 @@ export * from "./donation";
5
5
  export * from "./offer";
6
6
  export { MembershipGate, type MembershipGateProps, type MembershipGateState } from "./membership/MembershipGate";
7
7
  // S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
8
- export {
9
- useMembershipGateNotice,
10
- type UseMembershipGateNoticeInput,
11
- } from "./membership/useMembershipGateNotice";
8
+ export { useMembershipGateNotice, type UseMembershipGateNoticeInput } from "./membership/useMembershipGateNotice";
12
9
  export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
13
10
  export { useContactForm } from "./forms/useContactForm";
14
11
  export { useSectionedForm } from "./forms/useSectionedForm";
@@ -24,11 +21,7 @@ export {
24
21
  } from "./checkout/useCheckout";
25
22
  // Inventory holds — the reservation a buyer is given while they pay, and the
26
23
  // 409 they get when it lapses. Shared by both rendering stacks.
27
- export {
28
- useInventoryHold,
29
- type InventoryHoldState,
30
- type UseInventoryHoldOptions,
31
- } from "./checkout/useInventoryHold";
24
+ export { useInventoryHold, type InventoryHoldState, type UseInventoryHoldOptions } from "./checkout/useInventoryHold";
32
25
  // Cart recovery: what a resume link does when the buyer clicks it. Shared so
33
26
  // `apps/client` and a Forge code site cannot decide differently.
34
27
  export {
@@ -139,6 +132,10 @@ export * from "./memberHome";
139
132
  export * from "./reviews";
140
133
  // Document signing (contracts / e-signature) headless primitives.
141
134
  export * from "./document";
135
+ // Films and series headless primitives, including `<FilmWatermark>`. This is
136
+ // the sanctioned way to build a custom player - read `useFilmPlayback`'s
137
+ // docblock first, which says which protection layer you give up by doing so.
138
+ export * from "./film";
142
139
  // Work (project management) client-portal headless primitives.
143
140
  export * from "./work";
144
141
  // Funnel instrumentation — wrap a multi-step flow to record step-through and
package/src/ui/index.ts CHANGED
@@ -35,13 +35,7 @@ export {
35
35
  type ResolvedThemeTokens,
36
36
  } from "./theme/ForgeThemeProvider";
37
37
  export { readableTextOn } from "./theme/contrast";
38
- export {
39
- Button,
40
- buttonStyle,
41
- type ButtonProps,
42
- type ButtonVariant,
43
- type ButtonSize,
44
- } from "./styled/Button";
38
+ export { Button, buttonStyle, type ButtonProps, type ButtonVariant, type ButtonSize } from "./styled/Button";
45
39
  export {
46
40
  ForgePaymentProvider,
47
41
  usePaymentRenderer,
@@ -126,7 +120,12 @@ export { LoginForm, type LoginFormProps } from "./styled/LoginForm";
126
120
  export { SignupForm, type SignupFormProps } from "./styled/SignupForm";
127
121
  export { ForgotPasswordForm, type ForgotPasswordFormProps } from "./styled/ForgotPasswordForm";
128
122
  export { ResetPasswordForm, type ResetPasswordFormProps } from "./styled/ResetPasswordForm";
129
- export { AccountDashboard, type AccountDashboardProps, type AccountTabKey, ACCOUNT_TABS } from "./styled/AccountDashboard";
123
+ export {
124
+ AccountDashboard,
125
+ type AccountDashboardProps,
126
+ type AccountTabKey,
127
+ ACCOUNT_TABS,
128
+ } from "./styled/AccountDashboard";
130
129
  export {
131
130
  TicketTransferPanel,
132
131
  type TicketTransferPanelProps,
@@ -183,14 +182,8 @@ export { ReplayList, type ReplayListProps } from "./styled/ReplayList";
183
182
  export { LiveBroadcastList, type LiveBroadcastListProps } from "./styled/LiveBroadcastList";
184
183
  export { BroadcastWatch, type BroadcastWatchProps } from "./styled/BroadcastWatch";
185
184
  export { EndedBroadcast, type EndedBroadcastProps } from "./styled/broadcast/EndedBroadcast";
186
- export {
187
- BroadcastPassValidation,
188
- type BroadcastPassValidationProps,
189
- } from "./styled/broadcast/BroadcastPassValidation";
190
- export {
191
- BroadcastTicketPurchase,
192
- type BroadcastTicketPurchaseProps,
193
- } from "./styled/broadcast/BroadcastTicketPurchase";
185
+ export { BroadcastPassValidation, type BroadcastPassValidationProps } from "./styled/broadcast/BroadcastPassValidation";
186
+ export { BroadcastTicketPurchase, type BroadcastTicketPurchaseProps } from "./styled/broadcast/BroadcastTicketPurchase";
194
187
  export {
195
188
  BroadcastPlayer,
196
189
  type BroadcastPlayerProps,
@@ -200,6 +193,18 @@ export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/Con
200
193
  export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
201
194
  export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
202
195
  export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
196
+
197
+ // Films and series (Tier 2). `FilmWatch` owns the entitlement gate, the viewer
198
+ // overlay, the episode switcher and the clock; it takes a `renderPlayer` prop
199
+ // because Forge ships no HLS engine. See its docblock before restyling it: the
200
+ // five gate states each strand somebody if drawn as one of the others.
201
+ export { FilmCatalog, rentalWindowLabel, type FilmCatalogProps } from "./styled/FilmCatalog";
202
+ export { FilmDetail, type FilmDetailProps } from "./styled/FilmDetail";
203
+ // `FilmRentalList` is the library WITHOUT its page heading: the rows on their
204
+ // own, for a surface that supplies its own title (the account dashboard's
205
+ // Rentals tab does exactly this). Same rows, same live/expired rule.
206
+ export { FilmLibrary, FilmRentalList, type FilmLibraryProps, type FilmRentalListProps } from "./styled/FilmLibrary";
207
+ export { FilmWatch, type FilmWatchProps, type FilmPlayerRenderProps } from "./styled/FilmWatch";
203
208
  export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
204
209
  export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
205
210
  export { ReviewForm, type ReviewFormProps } from "./styled/ReviewForm";
@@ -144,6 +144,24 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
144
144
  backgroundColor: tokens.background,
145
145
  color: tokens.text,
146
146
  fontFamily: tokens.fontFamily,
147
+ /**
148
+ * Fill the surface rather than growing to fit the tiles.
149
+ *
150
+ * This laid out as a content-sized panel, which was right when a call
151
+ * was a modal and wrong the moment it became a full-screen page. With
152
+ * ONE participant `auto-fit` collapses the grid to a single full-width
153
+ * column, and a 16/9 tile at full width is taller than the viewport, so
154
+ * the controls were pushed off the bottom of the screen. Two people
155
+ * halved the width and hid the bug, which is why it only appeared to
156
+ * somebody sitting in a call alone.
157
+ *
158
+ * `100%` degrades to `auto` inside a parent with no height, so the
159
+ * embedded use (`BookingCallScreen`) is unaffected.
160
+ */
161
+ height: "100%",
162
+ minHeight: 0,
163
+ boxSizing: "border-box",
164
+ overflow: "hidden",
147
165
  }}
148
166
  >
149
167
  <div
@@ -174,6 +192,29 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
174
192
  display: "grid",
175
193
  gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
176
194
  gap: "0.75rem",
195
+ // The only part that may grow, and the only part that may scroll.
196
+ // `minHeight: 0` is what actually lets it shrink: a flex item defaults
197
+ // to `min-height: auto`, so without it the grid refuses to go below
198
+ // its content and pushes the controls out of the box instead.
199
+ flex: 1,
200
+ minHeight: 0,
201
+ overflowY: "auto",
202
+ /**
203
+ * Centred in the free space, not stacked at the top.
204
+ *
205
+ * `safe` is the load-bearing word: plain `center` centres the
206
+ * OVERFLOW too, so once there are more tiles than fit, the first row
207
+ * is pushed above the scroll origin and cannot be reached. `safe
208
+ * center` centres while there is room and falls back to start the
209
+ * moment there is not. An engine that does not understand it drops
210
+ * the declaration and starts at the top, which is the same fallback.
211
+ */
212
+ alignContent: "safe center",
213
+ // Horizontally centred too. A tile clamped by `maxHeight` shrinks its
214
+ // width to keep 16/9, so it no longer fills its track and would
215
+ // otherwise sit against the left edge. With two or more tiles each
216
+ // fills its track and this changes nothing.
217
+ justifyItems: "center",
177
218
  }}
178
219
  >
179
220
  <LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
@@ -291,6 +332,10 @@ const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: b
291
332
  position: "relative",
292
333
  overflow: "hidden",
293
334
  aspectRatio: String(aspectRatio),
335
+ // A lone tile spans the whole grid, and 16/9 of a wide surface is taller than
336
+ // the surface. Clamping crops rather than distorts, because the video inside
337
+ // is `object-fit: cover`.
338
+ maxHeight: "100%",
294
339
  borderRadius: tokens.cornerRadius,
295
340
  backgroundColor: tokens.surface,
296
341
  // The active speaker is shown by the FRAME rather than by re-ordering the
@@ -533,7 +578,20 @@ function CallControls({
533
578
  }, [room, onLeave]);
534
579
 
535
580
  return (
536
- <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.5rem" }}>
581
+ <div
582
+ style={{
583
+ display: "flex",
584
+ flexWrap: "wrap",
585
+ alignItems: "center",
586
+ gap: "0.5rem",
587
+ // Pinned to the bottom of the surface. The tile grid already claims the
588
+ // free space with `flex: 1`, so this holds even if that ever changes:
589
+ // the controls are the one thing that must never move off screen,
590
+ // because Leave is here.
591
+ marginTop: "auto",
592
+ flexShrink: 0,
593
+ }}
594
+ >
537
595
  {canPublishSource(grants, "microphone") && (
538
596
  <button
539
597
  type="button"