@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.
Files changed (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  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 +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,701 @@
1
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import { Loading } from "./Loading";
5
+ import { FilmWatermark } from "../headless/film/FilmWatermark";
6
+ import { useFilmPlayback, type FilmPlaybackGate, type FilmPlaybackSubtitle } from "../headless/film/useFilmPlayback";
7
+ import { useStageFullscreen } from "../headless/film/useStageFullscreen";
8
+ import {
9
+ groupFilmEpisodesBySeason,
10
+ nextFilmEpisode,
11
+ splitRemainingMs,
12
+ useFilm,
13
+ useMyFilmRentals,
14
+ type PublicFilmDetail,
15
+ type PublicFilmEpisode,
16
+ } from "../../data/queries/useFilms";
17
+ import { useForgeT, type ForgeT } from "../../i18n";
18
+
19
+ /**
20
+ * What a player renderer is handed. Everything it needs, nothing it could leak.
21
+ *
22
+ * There is no "stop" prop, because there does not need to be one: `<FilmWatch>`
23
+ * UNMOUNTS the renderer the moment playback must stop (a gate, or this tab being
24
+ * displaced under decision 7's one-stream cap), so the renderer's own cleanup is
25
+ * what tears the engine down. A renderer must therefore destroy its player in an
26
+ * effect cleanup rather than assume it will be told. A player built directly on
27
+ * `useFilmPlayback` instead of on this block reads `playback.stopped` for the
28
+ * same signal.
29
+ */
30
+ export interface FilmPlayerRenderProps {
31
+ /** The session-scoped, single-episode-bound master playlist. Changes only on recovery. */
32
+ src: string;
33
+ /** Poster art while nothing has loaded. */
34
+ poster?: string;
35
+ /**
36
+ * Sidecar WebVTT tracks, already absolute and already carrying the session
37
+ * token. Render one `<track kind="subtitles">` each, `default` on the one
38
+ * whose `isDefault` is true. Empty is the normal answer.
39
+ *
40
+ * Two things a renderer must do, both of which fail SILENTLY when skipped:
41
+ *
42
+ * - ⚠️ These live on the API's origin, not the site's, and a browser refuses
43
+ * to load a cross-origin `<track>` unless the `<video>` carries
44
+ * `crossOrigin="anonymous"`.
45
+ * - ⚠️ The token in each URL lives ten minutes and a `<track>` is fetched
46
+ * lazily, so force the cues down at mount (`textTrack.mode = "hidden"`) or
47
+ * a fan who enables subtitles late gets a 401 and an empty track.
48
+ *
49
+ * The starter's `FilmHlsPlayer` is the reference for both.
50
+ */
51
+ subtitles: FilmPlaybackSubtitle[];
52
+ /** Where to pick up. Seek to this once metadata is ready, then ignore it. */
53
+ startAtSec: number;
54
+ /** Fire as often as the player likes; the hook debounces the write. */
55
+ onTimeUpdate: (positionSec: number, durationSec?: number) => void;
56
+ onEnded: () => void;
57
+ /**
58
+ * A fatal playback error. Call it INSTEAD of reloading the page: the block
59
+ * re-derives the URL from a fresh token and hands back a new `src`, and the
60
+ * renderer restores its own position from `startAtSec`.
61
+ */
62
+ onFatalError: () => void;
63
+ }
64
+
65
+ export interface FilmWatchProps {
66
+ /** Route params. */
67
+ slug: string;
68
+ episodeId: string;
69
+ /**
70
+ * How to play encrypted HLS.
71
+ *
72
+ * Forge ships no HLS engine by design: a bare `<video src="…m3u8">` plays on
73
+ * Safari and nowhere else, and `EXT-X-KEY` fetching off Safari needs a real
74
+ * engine. The site-starter passes a player built on a client-only dynamic
75
+ * import of hls.js. The bare `<video>` is the fallback rather than the
76
+ * default-and-only, so a missing renderer degrades to "plays on Safari"
77
+ * instead of to a blank rectangle everywhere.
78
+ *
79
+ * Swapping this cannot defeat anything: the manifest handed to it is already
80
+ * session-scoped and episode-bound, and the key endpoint re-checks the rental
81
+ * on every fetch.
82
+ */
83
+ renderPlayer?: (props: FilmPlayerRenderProps) => ReactNode;
84
+ /** Where films live. Default `/i/films`. */
85
+ basePath?: string;
86
+ /** Sign-in path. Default `/i/login`. */
87
+ loginPath?: string;
88
+ /** SPA navigation adapter. Defaults to a full-page visit. */
89
+ navigate?: (href: string) => void;
90
+ /**
91
+ * Draw the dynamic viewer overlay. Default true, and leaving it true is the
92
+ * point: switching it off removes the only layer that ties a leaked recording
93
+ * to the RENTER rather than only to the film. Everything else that protects
94
+ * the stream is server-side and unaffected either way.
95
+ */
96
+ showWatermark?: boolean;
97
+ className?: string;
98
+ style?: CSSProperties;
99
+ }
100
+
101
+ /**
102
+ * The player surface: the gate, the stream, the overlay, the clock and the way
103
+ * to the next episode.
104
+ *
105
+ * ## The five states, and why none of them may be merged
106
+ *
107
+ * - **sign in** - nobody is signed in, and this is not a free preview. Needs a
108
+ * way IN that comes back here.
109
+ * - **wrong account** - signed in as somebody else. Needs a way OUT of the
110
+ * session they are already in. Never the words "sign in", which they have
111
+ * done.
112
+ * - **no rental** - nothing covers this episode. Needs the title page, where
113
+ * the offers are.
114
+ * - **expired** - there WAS a rental and the window closed. Needs a re-rent,
115
+ * not a sign-in, and needs to be told that plainly rather than shown an
116
+ * error.
117
+ * - **taken over** - somebody is watching this rental somewhere else. The
118
+ * player is torn down (see the overlay below: covering a playing video caps
119
+ * nothing), but this is NOT a dead end: one button takes it back, because
120
+ * takeover is the design and a crashed tab must never lock the buyer out.
121
+ *
122
+ * ## What is drawn over the video, and what that costs
123
+ *
124
+ * `<FilmWatermark>` sits INSIDE the player surface, not beside it, so it shares
125
+ * the pixels a screen recording captures. It deters the record-and-share leak by
126
+ * naming the leaker; it prevents nothing. A creator building a custom player
127
+ * from `useFilmPlayback` can leave it out, and if they do, a leaked recording is
128
+ * still traceable to the film (that watermark is burned into the pixels by the
129
+ * transcoder) but no longer to the renter.
130
+ */
131
+ export function FilmWatch({
132
+ slug,
133
+ episodeId,
134
+ renderPlayer,
135
+ basePath = "/i/films",
136
+ loginPath = "/i/login",
137
+ navigate,
138
+ showWatermark = true,
139
+ className,
140
+ style,
141
+ }: FilmWatchProps) {
142
+ const t = useForgeT();
143
+ const tokens = useThemeTokens();
144
+ const { user, isInitialized } = usePublicAuth();
145
+ const go = navigate ?? ((href: string) => window.location.assign(href));
146
+ const [ended, setEnded] = useState(false);
147
+ const fullscreen = useStageFullscreen();
148
+
149
+ const detail = useFilm(slug, { accountId: user?.id });
150
+
151
+ /**
152
+ * The fallback that stops an archived title from stranding a paying fan.
153
+ *
154
+ * `/public/films/:slug` 404s for an archived title and while the feature
155
+ * switch is off, but an active rental keeps playing in both cases - turning a
156
+ * switch off must never eat something a fan paid for. The fan's own library
157
+ * always carries the rental with its film's slug, so it is the one place the
158
+ * id can still be resolved. Only asked for when the detail actually failed.
159
+ */
160
+ const fallback = useMyFilmRentals(detail.isError ? user?.id : undefined, 1, 50);
161
+ const fallbackFilm = useMemo(() => {
162
+ if (!detail.isError) return null;
163
+ const match = (fallback.data?.data ?? []).find((rental) => rental.film.slug === slug);
164
+ return match?.film ?? null;
165
+ }, [detail.isError, fallback.data, slug]);
166
+
167
+ const film: PublicFilmDetail | null = detail.data ?? null;
168
+ const filmId = film?.id ?? fallbackFilm?.id;
169
+ const episode = film?.episodes.find((candidate) => candidate.id === episodeId) ?? null;
170
+
171
+ const playback = useFilmPlayback({
172
+ filmId,
173
+ episodeId,
174
+ loginPath,
175
+ isFreePreview: episode?.isFreePreview,
176
+ enabled: !!filmId,
177
+ });
178
+
179
+ // ⚠️ EVERY HOOK MUST SIT ABOVE THE EARLY RETURNS BELOW. This block lived under
180
+ // them and produced "Rendered more hooks than during the previous render": the
181
+ // loading render bailed at the guard having called fewer hooks than the render
182
+ // that got past it. The effect guards on its own ref instead, which is null
183
+ // until the stage mounts.
184
+ /**
185
+ * The fallback `<video>`, and the one thing it has to do about subtitles.
186
+ *
187
+ * ⚠️ This block tells every renderer to force the cues down at mount, and the
188
+ * fallback below is a renderer. A `<track>` is fetched LAZILY, when the viewer
189
+ * picks a language out of the captions menu, and the session token baked into
190
+ * its URL lives ten minutes. Eleven minutes in, that fetch carries an expired
191
+ * token, the subtitle endpoint answers 401, and the viewer gets a menu entry
192
+ * that is selected and empty: a `<track>` load failure fires an `error` event
193
+ * on the element and nothing else, so there is nowhere for it to surface.
194
+ *
195
+ * `mode = "hidden"` fetches and parses the cues without displaying them, so
196
+ * every language is already in memory before anyone asks for one. Only
197
+ * `disabled` tracks are touched, leaving the `default` one in the `showing`
198
+ * mode it loaded with, and the menu still reads as off until the viewer picks.
199
+ *
200
+ * The starter's `FilmHlsPlayer` carries the same effect for the same reason;
201
+ * this is not a duplicate of it, it is the fallback path honouring the rule
202
+ * the props above set.
203
+ */
204
+ const fallbackVideoRef = useRef<HTMLVideoElement | null>(null);
205
+ useEffect(() => {
206
+ const video = fallbackVideoRef.current;
207
+ if (!video || playback.subtitles.length === 0) return;
208
+ for (const textTrack of Array.from(video.textTracks)) {
209
+ if (textTrack.mode === "disabled") textTrack.mode = "hidden";
210
+ }
211
+ }, [playback.subtitles, playback.src, playback.stopped, renderPlayer]);
212
+
213
+ const resolvingFilm = detail.isLoading || (detail.isError && fallback.isLoading);
214
+ if (!isInitialized || resolvingFilm || playback.isLoading) return <Loading fullPage />;
215
+
216
+ const titleHref = `${basePath}/${slug}`;
217
+
218
+ if (playback.gate) {
219
+ return (
220
+ <GatePanel
221
+ gate={playback.gate}
222
+ t={t}
223
+ onSignIn={() => go(playback.signInHref)}
224
+ onSignOut={() => void playback.signOutAndRetry()}
225
+ onTitle={() => go(titleHref)}
226
+ signedInEmail={playback.signedInEmail}
227
+ className={className}
228
+ style={style}
229
+ />
230
+ );
231
+ }
232
+
233
+ if (!filmId || !playback.src) {
234
+ return (
235
+ <Panel className={className} style={style}>
236
+ <p style={{ fontSize: 14, color: tokens.muted }}>{t("forge.film_watch.unavailable")}</p>
237
+ </Panel>
238
+ );
239
+ }
240
+
241
+ const remaining = splitRemainingMs(playback.msRemaining);
242
+ const next = film ? nextFilmEpisode(film.episodes, episodeId) : null;
243
+ const seasons = film ? groupFilmEpisodesBySeason(film.episodes) : [];
244
+ const episodeLabel = playback.episode
245
+ ? t("forge.film_watch.episode_label", {
246
+ season: playback.episode.seasonNumber,
247
+ number: playback.episode.episodeNumber,
248
+ })
249
+ : null;
250
+
251
+ const playerProps: FilmPlayerRenderProps = {
252
+ src: playback.src,
253
+ poster: film?.cover?.url,
254
+ subtitles: playback.subtitles,
255
+ startAtSec: playback.resumeAtSec,
256
+ onTimeUpdate: playback.reportProgress,
257
+ onEnded: () => setEnded(true),
258
+ onFatalError: () => void playback.recoverPlayback(),
259
+ };
260
+
261
+ return (
262
+ <div
263
+ className={className}
264
+ style={{
265
+ display: "flex",
266
+ flexDirection: "column",
267
+ gap: 16,
268
+ maxWidth: 1100,
269
+ margin: "0 auto",
270
+ color: tokens.text,
271
+ fontFamily: tokens.fontFamily,
272
+ ...style,
273
+ }}
274
+ >
275
+ {/* THE STAGE IS THE FULLSCREEN ELEMENT, never the <video>. The browser
276
+ promotes one element and hides the rest of the document, so a native
277
+ fullscreen on the video would leave the watermark behind - see
278
+ `useStageFullscreen`. Everything that must survive fullscreen has to be
279
+ a DESCENDANT of this node. */}
280
+ <div
281
+ ref={fullscreen.ref}
282
+ data-testid="film-stage"
283
+ style={{
284
+ position: "relative",
285
+ width: "100%",
286
+ // In fullscreen the stage IS the viewport, so a 16/9 box would letterbox
287
+ // inside an already letterboxed screen.
288
+ ...(fullscreen.isFullscreen
289
+ ? { height: "100%", aspectRatio: undefined, borderRadius: 0 }
290
+ : { aspectRatio: "16/9", borderRadius: tokens.cornerRadius }),
291
+ // The one accepted literal in this file: the letterbox behind a video,
292
+ // which has to be black whatever the theme is.
293
+ background: "#000",
294
+ overflow: "hidden",
295
+ }}
296
+ >
297
+ {/* UNMOUNTED once this tab has been displaced, never merely covered.
298
+ Decision 7 caps a rental at one stream, and an overlay painted over a
299
+ still-playing <video> caps nothing: the engine has already fetched
300
+ the key and holds hours of presigned segment URLs the server cannot
301
+ revoke, so both devices would watch the whole film, audio included on
302
+ this one. Unmounting runs the renderer's cleanup - hls.js is
303
+ destroyed, the element is torn down - which is the only thing here
304
+ that actually stops the bytes. The overlay below still offers the way
305
+ back, so this is a stop, not a dead end. */}
306
+ {!playback.stopped &&
307
+ (renderPlayer ? (
308
+ renderPlayer(playerProps)
309
+ ) : (
310
+ <video
311
+ ref={fallbackVideoRef}
312
+ src={playback.src}
313
+ // CORS mode is what lets a cross-origin <track> load at all, and
314
+ // it is asked for only when there is a track to load: it also puts
315
+ // the POSTER fetch into CORS mode, and the media bucket does not
316
+ // allow creator custom domains, so a poster requested that way
317
+ // silently fails to paint. A title with no subtitles keeps its
318
+ // poster; a title with them trades it for the captions.
319
+ {...(playback.subtitles.length > 0
320
+ ? { crossOrigin: "anonymous" as const }
321
+ : { poster: film?.cover?.url })}
322
+ controls
323
+ playsInline
324
+ autoPlay
325
+ onLoadedMetadata={(e) => {
326
+ if (playback.resumeAtSec > 0) e.currentTarget.currentTime = playback.resumeAtSec;
327
+ }}
328
+ onTimeUpdate={(e) => playback.reportProgress(e.currentTarget.currentTime, e.currentTarget.duration)}
329
+ onEnded={() => setEnded(true)}
330
+ style={{ width: "100%", height: "100%", display: "block" }}
331
+ >
332
+ {playback.subtitles.map((track) => (
333
+ <track
334
+ key={track.id}
335
+ kind="subtitles"
336
+ src={track.src}
337
+ srcLang={track.language}
338
+ label={track.label}
339
+ default={track.isDefault}
340
+ />
341
+ ))}
342
+ </video>
343
+ ))}
344
+
345
+ {showWatermark && !playback.stopped && playback.watermark && (
346
+ <FilmWatermark
347
+ name={playback.watermark.name}
348
+ email={playback.watermark.email}
349
+ rentalRef={playback.watermark.rentalRef}
350
+ />
351
+ )}
352
+
353
+ {/* The stage's own fullscreen control. On Chromium the player hides the
354
+ native one (`controlsList="nofullscreen"`), so this is the only route
355
+ and the watermark always travels. Elsewhere the native button still
356
+ works and `useStageFullscreen` corrects it. */}
357
+ {fullscreen.isSupported && !playback.stopped && (
358
+ <button
359
+ type="button"
360
+ onClick={fullscreen.toggle}
361
+ aria-label={fullscreen.isFullscreen ? t("forge.film_watch.exit_fullscreen") : t("forge.film_watch.enter_fullscreen")}
362
+ style={{
363
+ position: "absolute",
364
+ right: 12,
365
+ top: 12,
366
+ zIndex: 3,
367
+ cursor: "pointer",
368
+ border: "none",
369
+ borderRadius: tokens.cornerRadius,
370
+ padding: "6px 10px",
371
+ fontSize: 12,
372
+ fontFamily: tokens.fontFamily,
373
+ // Sits on the black stage, so theme surface tokens would vanish
374
+ // against it.
375
+ background: "rgba(0,0,0,0.55)",
376
+ color: "#ffffff",
377
+ }}
378
+ >
379
+ {fullscreen.isFullscreen ? t("forge.film_watch.exit_fullscreen") : t("forge.film_watch.enter_fullscreen")}
380
+ </button>
381
+ )}
382
+
383
+ {/* Takeover is drawn OVER the stage rather than replacing the page: the
384
+ fan is one click from taking their rental back, and losing the page
385
+ they were on would make that feel like a punishment. */}
386
+ {playback.takenOver && (
387
+ <div
388
+ role="alert"
389
+ style={{
390
+ position: "absolute",
391
+ inset: 0,
392
+ zIndex: 30,
393
+ display: "flex",
394
+ flexDirection: "column",
395
+ alignItems: "center",
396
+ justifyContent: "center",
397
+ gap: 14,
398
+ padding: 24,
399
+ textAlign: "center",
400
+ background: "rgba(0,0,0,0.82)",
401
+ color: "#ffffff",
402
+ }}
403
+ >
404
+ <p style={{ fontSize: 17, fontWeight: 700 }}>{t("forge.film_watch.taken_over_title")}</p>
405
+ <p style={{ fontSize: 14, maxWidth: 420, lineHeight: 1.6 }}>{t("forge.film_watch.taken_over_body")}</p>
406
+ <button
407
+ type="button"
408
+ onClick={() => void playback.resumeHere()}
409
+ style={{
410
+ padding: "10px 20px",
411
+ borderRadius: tokens.cornerRadius,
412
+ border: "none",
413
+ background: tokens.primary,
414
+ color: tokens.textPrimary,
415
+ fontSize: 14,
416
+ fontWeight: 700,
417
+ cursor: "pointer",
418
+ }}
419
+ >
420
+ {t("forge.film_watch.taken_over_action")}
421
+ </button>
422
+ </div>
423
+ )}
424
+ </div>
425
+
426
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
427
+ <div>
428
+ <h1
429
+ style={{
430
+ fontSize: 22,
431
+ fontWeight: 800,
432
+ fontFamily: tokens.headingFontFamily || tokens.fontFamily,
433
+ }}
434
+ >
435
+ {film?.title ?? fallbackFilm?.title ?? ""}
436
+ </h1>
437
+ {film?.kind === "series" && episodeLabel && (
438
+ <p style={{ fontSize: 14, color: tokens.muted }}>
439
+ {episodeLabel}
440
+ {playback.episode?.title ? ` · ${playback.episode.title}` : ""}
441
+ </p>
442
+ )}
443
+ </div>
444
+
445
+ {/* Time remaining is content, never help: it is the thing the fan is
446
+ spending. Free previews have no window, so they get no clock. */}
447
+ {!playback.isFreePreview && (
448
+ <p style={{ fontSize: 14, fontWeight: 600, alignSelf: "center" }}>
449
+ {playback.msRemaining == null
450
+ ? t("forge.film_watch.window_not_started")
451
+ : remaining && remaining.days > 0
452
+ ? t("forge.film_watch.time_left_days", { days: remaining.days, hours: remaining.hours })
453
+ : remaining && remaining.hours > 0
454
+ ? t("forge.film_watch.time_left_hours", { hours: remaining.hours, minutes: remaining.minutes })
455
+ : t("forge.film_watch.time_left_minutes", { minutes: remaining?.minutes ?? 0 })}
456
+ </p>
457
+ )}
458
+ </div>
459
+
460
+ {/* Decision 12, said out loud at the only moment it can still be acted on.
461
+ Every rental is a whole-title rental, so there is no case where this
462
+ sentence would be untrue: a free preview has no rental and no clock. */}
463
+ {playback.clockStartedNow && playback.rental && (
464
+ <p
465
+ style={{
466
+ fontSize: 13,
467
+ lineHeight: 1.6,
468
+ padding: "10px 14px",
469
+ borderRadius: tokens.cornerRadius,
470
+ border: `1px solid ${tokens.border}`,
471
+ background: `${tokens.primary}12`,
472
+ }}
473
+ >
474
+ {t("forge.film_watch.clock_started", { hours: playback.rental.durationHours })}
475
+ </p>
476
+ )}
477
+
478
+ {ended && next && (
479
+ <div
480
+ style={{
481
+ display: "flex",
482
+ alignItems: "center",
483
+ gap: 12,
484
+ flexWrap: "wrap",
485
+ padding: "12px 16px",
486
+ borderRadius: tokens.cornerRadius,
487
+ border: `1px solid ${tokens.border}`,
488
+ background: tokens.surface,
489
+ }}
490
+ >
491
+ <span style={{ flex: "1 1 220px", fontSize: 14 }}>
492
+ {t("forge.film_watch.up_next", {
493
+ title:
494
+ next.title ||
495
+ t("forge.film_watch.episode_label", { season: next.seasonNumber, number: next.episodeNumber }),
496
+ })}
497
+ </span>
498
+ <button
499
+ type="button"
500
+ onClick={() => go(`${basePath}/${slug}/watch/${next.id}`)}
501
+ style={{
502
+ padding: "9px 16px",
503
+ borderRadius: tokens.cornerRadius,
504
+ border: "none",
505
+ background: tokens.primary,
506
+ color: tokens.textPrimary,
507
+ fontSize: 13,
508
+ fontWeight: 700,
509
+ cursor: "pointer",
510
+ }}
511
+ >
512
+ {t("forge.film_watch.play_next")}
513
+ </button>
514
+ </div>
515
+ )}
516
+
517
+ {seasons.length > 0 && film?.kind === "series" && (
518
+ <section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
519
+ <h2 style={{ fontSize: 15, fontWeight: 700 }}>{t("forge.film_watch.episodes")}</h2>
520
+ {seasons.map((season) => (
521
+ <div key={season.seasonNumber} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
522
+ {seasons.length > 1 && (
523
+ <h3 style={{ fontSize: 13, fontWeight: 700, color: tokens.muted }}>
524
+ {t("forge.film_watch.season", { number: season.seasonNumber })}
525
+ </h3>
526
+ )}
527
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
528
+ {season.episodes.map((candidate) => (
529
+ <EpisodeChip
530
+ key={candidate.id}
531
+ episode={candidate}
532
+ t={t}
533
+ isCurrent={candidate.id === episodeId}
534
+ onSelect={() => go(`${basePath}/${slug}/watch/${candidate.id}`)}
535
+ />
536
+ ))}
537
+ </div>
538
+ </div>
539
+ ))}
540
+ </section>
541
+ )}
542
+ </div>
543
+ );
544
+ }
545
+
546
+ function EpisodeChip({
547
+ episode,
548
+ t,
549
+ isCurrent,
550
+ onSelect,
551
+ }: {
552
+ episode: PublicFilmEpisode;
553
+ t: ForgeT;
554
+ isCurrent: boolean;
555
+ onSelect: () => void;
556
+ }) {
557
+ const tokens = useThemeTokens();
558
+ return (
559
+ <button
560
+ type="button"
561
+ onClick={onSelect}
562
+ aria-current={isCurrent ? "true" : undefined}
563
+ title={episode.title ?? undefined}
564
+ style={{
565
+ padding: "7px 12px",
566
+ borderRadius: tokens.cornerRadius,
567
+ border: `1px solid ${isCurrent ? tokens.primary : tokens.border}`,
568
+ background: isCurrent ? `${tokens.primary}20` : "transparent",
569
+ color: isCurrent ? tokens.primary : tokens.text,
570
+ fontSize: 13,
571
+ fontWeight: isCurrent ? 700 : 500,
572
+ cursor: "pointer",
573
+ fontFamily: tokens.fontFamily,
574
+ }}
575
+ >
576
+ {t("forge.film_watch.episode_short", { number: episode.episodeNumber })}
577
+ {episode.isFreePreview ? ` · ${t("forge.film_watch.free")}` : ""}
578
+ </button>
579
+ );
580
+ }
581
+
582
+ function Panel({ children, className, style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
583
+ const tokens = useThemeTokens();
584
+ return (
585
+ <div
586
+ className={className}
587
+ style={{
588
+ maxWidth: 520,
589
+ margin: "0 auto",
590
+ padding: 28,
591
+ borderRadius: tokens.cornerRadius,
592
+ border: `1px solid ${tokens.border}`,
593
+ background: tokens.surface,
594
+ color: tokens.text,
595
+ textAlign: "center",
596
+ display: "flex",
597
+ flexDirection: "column",
598
+ gap: 12,
599
+ fontFamily: tokens.fontFamily,
600
+ ...style,
601
+ }}
602
+ >
603
+ {children}
604
+ </div>
605
+ );
606
+ }
607
+
608
+ function GatePanel({
609
+ gate,
610
+ t,
611
+ onSignIn,
612
+ onSignOut,
613
+ onTitle,
614
+ signedInEmail,
615
+ className,
616
+ style,
617
+ }: {
618
+ gate: FilmPlaybackGate;
619
+ t: ForgeT;
620
+ onSignIn: () => void;
621
+ onSignOut: () => void;
622
+ onTitle: () => void;
623
+ signedInEmail: string | null;
624
+ className?: string;
625
+ style?: CSSProperties;
626
+ }) {
627
+ const tokens = useThemeTokens();
628
+
629
+ const copy: Record<FilmPlaybackGate["kind"], { title: string; body: string; action: string | null }> = {
630
+ sign_in_required: {
631
+ title: t("forge.film_watch.gate_sign_in_title"),
632
+ body: t("forge.film_watch.gate_sign_in_body"),
633
+ action: t("forge.film_watch.gate_sign_in_action"),
634
+ },
635
+ wrong_account: {
636
+ title: t("forge.film_watch.gate_wrong_account_title"),
637
+ body: t("forge.film_watch.gate_wrong_account_body"),
638
+ action: t("forge.film_watch.gate_wrong_account_action"),
639
+ },
640
+ no_rental: {
641
+ title: t("forge.film_watch.gate_no_rental_title"),
642
+ body: t("forge.film_watch.gate_no_rental_body"),
643
+ action: t("forge.film_watch.gate_no_rental_action"),
644
+ },
645
+ expired: {
646
+ title: t("forge.film_watch.gate_expired_title"),
647
+ body: t("forge.film_watch.gate_expired_body"),
648
+ action: t("forge.film_watch.gate_expired_action"),
649
+ },
650
+ unavailable: {
651
+ title: t("forge.film_watch.gate_unavailable_title"),
652
+ body: t("forge.film_watch.gate_unavailable_body"),
653
+ action: t("forge.film_watch.gate_unavailable_action"),
654
+ },
655
+ };
656
+
657
+ const chosen = copy[gate.kind];
658
+ const onAction = gate.kind === "sign_in_required" ? onSignIn : gate.kind === "wrong_account" ? onSignOut : onTitle;
659
+
660
+ return (
661
+ <Panel className={className} style={style}>
662
+ <h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: tokens.headingFontFamily || tokens.fontFamily }}>
663
+ {chosen.title}
664
+ </h1>
665
+ {/* The API's own sentence wins when it sent one - it is already translated
666
+ and it knows more about the refusal than this component does. */}
667
+ <p style={{ fontSize: 14, color: tokens.muted, lineHeight: 1.6 }}>{gate.message ?? chosen.body}</p>
668
+
669
+ {/* Naming the session they are in is what makes "wrong account"
670
+ actionable: otherwise the reader has no idea which of their addresses
671
+ they are currently signed in with. */}
672
+ {gate.kind === "wrong_account" && signedInEmail && (
673
+ <p style={{ fontSize: 13, color: tokens.muted }}>
674
+ {t("forge.film_watch.signed_in_as")} <strong style={{ color: tokens.text }}>{signedInEmail}</strong>
675
+ </p>
676
+ )}
677
+
678
+ {chosen.action && (
679
+ <button
680
+ type="button"
681
+ onClick={onAction}
682
+ style={{
683
+ alignSelf: "center",
684
+ padding: "10px 20px",
685
+ borderRadius: tokens.cornerRadius,
686
+ border: "none",
687
+ background: tokens.primary,
688
+ color: tokens.textPrimary,
689
+ fontSize: 14,
690
+ fontWeight: 700,
691
+ cursor: "pointer",
692
+ }}
693
+ >
694
+ {chosen.action}
695
+ </button>
696
+ )}
697
+ </Panel>
698
+ );
699
+ }
700
+
701
+ export default FilmWatch;