@tribe-nest/forge 3.31.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 +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- 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 +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- 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 +194 -0
- package/src/ui/styled/forge-utilities.css +835 -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
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
// Forge UI — Tier-2 styled blocks (theme-aware, opinionated) + the theme
|
|
2
2
|
// contract. Built on the Tier-1 headless primitives in `forge/ui/headless`.
|
|
3
|
+
|
|
4
|
+
// Compiled Tailwind utilities for every className in Forge's source, generated
|
|
5
|
+
// by `npm run build:css` (see tailwind.input.css for why the consumer site's
|
|
6
|
+
// own Tailwind scan cannot be relied on). Bundlers deliver a side-effect CSS
|
|
7
|
+
// import from a library the same way the rich-text.css imports already ship.
|
|
8
|
+
// Compiled Tailwind utilities for every className in Forge's source, generated
|
|
9
|
+
// by `npm run build:css` (see tailwind.input.css for why the consumer site's
|
|
10
|
+
// own Tailwind scan cannot be relied on). Bundlers deliver a side-effect CSS
|
|
11
|
+
// import from a library the same way the rich-text.css imports already ship.
|
|
12
|
+
//
|
|
13
|
+
// This import MUST stay a side effect, and the reason is not stylistic. A site
|
|
14
|
+
// deployed before this shipped has a `styles.css` we never rewrite: Update
|
|
15
|
+
// Theme repins Forge's version and touches nothing else. So an explicit
|
|
16
|
+
// `@import` in the starter reaches NEW sites only, and every existing site
|
|
17
|
+
// would silently lose its Forge layouts the moment it repinned. Delivering
|
|
18
|
+
// from the package is the only route that reaches a site nobody edits.
|
|
19
|
+
//
|
|
20
|
+
// The cost is that it reaches every consumer, including our own apps, and the
|
|
21
|
+
// file is not shy: `@layer theme` redefines 23 root design tokens and
|
|
22
|
+
// `@layer properties` resets 43 `--tw-*` on the universal selector. That made
|
|
23
|
+
// the admin dashboard sidebar disappear (2026-08-22). Admin therefore aliases
|
|
24
|
+
// this file to an empty stub in its vite config and `@source`s Forge's source
|
|
25
|
+
// instead. A monorepo app that renders Forge components should do the same.
|
|
26
|
+
import "./styled/forge-utilities.css";
|
|
27
|
+
|
|
3
28
|
export {
|
|
4
29
|
ForgeThemeProvider,
|
|
5
30
|
useForgeTheme,
|
|
@@ -10,13 +35,7 @@ export {
|
|
|
10
35
|
type ResolvedThemeTokens,
|
|
11
36
|
} from "./theme/ForgeThemeProvider";
|
|
12
37
|
export { readableTextOn } from "./theme/contrast";
|
|
13
|
-
export {
|
|
14
|
-
Button,
|
|
15
|
-
buttonStyle,
|
|
16
|
-
type ButtonProps,
|
|
17
|
-
type ButtonVariant,
|
|
18
|
-
type ButtonSize,
|
|
19
|
-
} from "./styled/Button";
|
|
38
|
+
export { Button, buttonStyle, type ButtonProps, type ButtonVariant, type ButtonSize } from "./styled/Button";
|
|
20
39
|
export {
|
|
21
40
|
ForgePaymentProvider,
|
|
22
41
|
usePaymentRenderer,
|
|
@@ -101,7 +120,12 @@ export { LoginForm, type LoginFormProps } from "./styled/LoginForm";
|
|
|
101
120
|
export { SignupForm, type SignupFormProps } from "./styled/SignupForm";
|
|
102
121
|
export { ForgotPasswordForm, type ForgotPasswordFormProps } from "./styled/ForgotPasswordForm";
|
|
103
122
|
export { ResetPasswordForm, type ResetPasswordFormProps } from "./styled/ResetPasswordForm";
|
|
104
|
-
export {
|
|
123
|
+
export {
|
|
124
|
+
AccountDashboard,
|
|
125
|
+
type AccountDashboardProps,
|
|
126
|
+
type AccountTabKey,
|
|
127
|
+
ACCOUNT_TABS,
|
|
128
|
+
} from "./styled/AccountDashboard";
|
|
105
129
|
export {
|
|
106
130
|
TicketTransferPanel,
|
|
107
131
|
type TicketTransferPanelProps,
|
|
@@ -158,14 +182,8 @@ export { ReplayList, type ReplayListProps } from "./styled/ReplayList";
|
|
|
158
182
|
export { LiveBroadcastList, type LiveBroadcastListProps } from "./styled/LiveBroadcastList";
|
|
159
183
|
export { BroadcastWatch, type BroadcastWatchProps } from "./styled/BroadcastWatch";
|
|
160
184
|
export { EndedBroadcast, type EndedBroadcastProps } from "./styled/broadcast/EndedBroadcast";
|
|
161
|
-
export {
|
|
162
|
-
|
|
163
|
-
type BroadcastPassValidationProps,
|
|
164
|
-
} from "./styled/broadcast/BroadcastPassValidation";
|
|
165
|
-
export {
|
|
166
|
-
BroadcastTicketPurchase,
|
|
167
|
-
type BroadcastTicketPurchaseProps,
|
|
168
|
-
} from "./styled/broadcast/BroadcastTicketPurchase";
|
|
185
|
+
export { BroadcastPassValidation, type BroadcastPassValidationProps } from "./styled/broadcast/BroadcastPassValidation";
|
|
186
|
+
export { BroadcastTicketPurchase, type BroadcastTicketPurchaseProps } from "./styled/broadcast/BroadcastTicketPurchase";
|
|
169
187
|
export {
|
|
170
188
|
BroadcastPlayer,
|
|
171
189
|
type BroadcastPlayerProps,
|
|
@@ -175,6 +193,15 @@ export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/Con
|
|
|
175
193
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
176
194
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
177
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";
|
|
178
205
|
export { DonationPage, type DonationPageProps } from "./styled/DonationPage";
|
|
179
206
|
export { ReviewsSection, StarRow, type ReviewsSectionProps } from "./styled/ReviewsSection";
|
|
180
207
|
export { ReviewForm, type ReviewFormProps } from "./styled/ReviewForm";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { CallStage } from "./CallStage";
|
|
4
|
+
import { BookingCallProvider } from "./bookingSession";
|
|
5
|
+
|
|
6
|
+
export type BookingCallScreenProps = {
|
|
7
|
+
bookingId: string;
|
|
8
|
+
/** Shown as the call's title: the product the session was bought on. */
|
|
9
|
+
title: string;
|
|
10
|
+
/** Leave. The caller unmounts this subtree, which is what ends the call. */
|
|
11
|
+
onLeave: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One booking's call: the provider wired to the credential endpoint, and the
|
|
16
|
+
* one call UI inside it.
|
|
17
|
+
*
|
|
18
|
+
* A DEFAULT export on purpose. `AccountDashboard` (in the `./ui` entry every
|
|
19
|
+
* site loads) reaches this through `React.lazy(() => import(...))`, so the
|
|
20
|
+
* media SDK and mediasoup are fetched by the browser only when somebody
|
|
21
|
+
* actually presses Join, rather than by every visitor to every account page.
|
|
22
|
+
* The provider connects on mount and closes on unmount, so the caller's
|
|
23
|
+
* "joined" state is exactly the call's lifetime: leaving goes through state,
|
|
24
|
+
* never through a `close()` that leaves a live provider mounted to reconnect
|
|
25
|
+
* on its next render.
|
|
26
|
+
*/
|
|
27
|
+
export default function BookingCallScreen({ bookingId, title, onLeave }: BookingCallScreenProps): ReactNode {
|
|
28
|
+
return (
|
|
29
|
+
<BookingCallProvider bookingId={bookingId}>
|
|
30
|
+
<CallStage title={title} onLeave={onLeave} />
|
|
31
|
+
</BookingCallProvider>
|
|
32
|
+
);
|
|
33
|
+
}
|