@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.
- 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/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/i18n/de.json +128 -28
- package/src/i18n/en.json +128 -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/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 +18 -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 +28 -36
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- 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
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,15 @@ 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
|
+
export { FilmLibrary, type FilmLibraryProps } from "./styled/FilmLibrary";
|
|
204
|
+
export { FilmWatch, type FilmWatchProps, type FilmPlayerRenderProps } from "./styled/FilmWatch";
|
|
203
205
|
export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
|
|
204
206
|
export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
|
|
205
207
|
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
|
|
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"
|