@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,661 @@
1
+ import { useMemo, type CSSProperties } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { useFormatCurrency } from "../format/useFormatCurrency";
4
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
5
+ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
+ import { PaystackPayButton } from "./PaystackPayButton";
7
+ import { CallHelpHint } from "../media/CallHelpHint";
8
+ import { Loading } from "./Loading";
9
+ import { rentalWindowLabel } from "./FilmCatalog";
10
+ import { useFilmRentalFlow } from "../headless/film/useFilmRentalFlow";
11
+ import { filmSignInHref } from "../headless/film/useFilmPlayback";
12
+ import {
13
+ canWatchFilmEpisode,
14
+ groupFilmEpisodesBySeason,
15
+ isFilmEpisodeReleased,
16
+ splitDurationSec,
17
+ splitRemainingMs,
18
+ useFilm,
19
+ type PublicFilmDetail,
20
+ type PublicFilmEpisode,
21
+ type PublicFilmRental,
22
+ type PublicFilmRentalOption,
23
+ } from "../../data/queries/useFilms";
24
+ import { useForgeT, type ForgeT } from "../../i18n";
25
+ import {
26
+ DialogRoot,
27
+ DialogPortal,
28
+ DialogOverlay,
29
+ DialogContent,
30
+ DialogTitle,
31
+ DialogClose,
32
+ } from "../headless/dialog";
33
+
34
+ export interface FilmDetailProps {
35
+ /** Route param. */
36
+ slug: string;
37
+ /** SSR-loaded detail, so the first paint is not a spinner. */
38
+ initialFilm?: PublicFilmDetail;
39
+ /** A rental id read off the return URL - finalizes the purchase on mount. */
40
+ finalizeRentalId?: string;
41
+ /** Where films live. Default `/i/films`. */
42
+ basePath?: string;
43
+ /** Sign-in path. Default `/i/login`. */
44
+ loginPath?: string;
45
+ /** SPA navigation adapter. Defaults to a full-page visit. */
46
+ navigate?: (href: string) => void;
47
+ className?: string;
48
+ style?: CSSProperties;
49
+ }
50
+
51
+ /**
52
+ * One title: poster, trailer, what it costs, and the way in.
53
+ *
54
+ * The rule this block exists to honour is that **the window is stated before
55
+ * the purchase, never discovered after it**. A rental's clock is a whole-title
56
+ * clock that starts at first play, so a fan who buys a ten-episode series on a
57
+ * 48-hour window and finds out at expiry has been mis-sold. The rent button
58
+ * therefore carries its window in its own label, not in a footnote, and the
59
+ * "when does it start" explanation - which somebody who already knows the
60
+ * product would skip every time - sits behind a `?`.
61
+ *
62
+ * There is exactly ONE purchase CTA on this page, and on a series it says "rent
63
+ * the whole series". The episode list underneath it sells nothing: a row is a
64
+ * Watch button once the viewer holds a live rental (or the episode is a free
65
+ * preview), and otherwise it is just a row.
66
+ *
67
+ * A title may be sold in several windows (8 hours cheaply, 48 hours for more).
68
+ * When there is more than one, they are drawn IN THE CREATOR'S OWN ORDER (the
69
+ * one the admin windows editor promises: "Fans see these in this order, top
70
+ * first") and the fan picks before the CTA fires. The pre-selected window is
71
+ * still the cheapest one, which is a separate question from which is shown
72
+ * first. When there is exactly ONE, no picker is drawn at all: a
73
+ * chooser offering a choice of one is a control that cannot be used, and it
74
+ * makes a simple title read as a complicated one. Either way the CTA carries the
75
+ * price AND the window of what is actually about to be bought.
76
+ */
77
+ export function FilmDetail({
78
+ slug,
79
+ initialFilm,
80
+ finalizeRentalId,
81
+ basePath = "/i/films",
82
+ loginPath = "/i/login",
83
+ navigate,
84
+ className,
85
+ style,
86
+ }: FilmDetailProps) {
87
+ const t = useForgeT();
88
+ const tokens = useThemeTokens();
89
+ const { formatCurrency } = useFormatCurrency();
90
+ const { user, isInitialized } = usePublicAuth();
91
+ const go = navigate ?? ((href: string) => window.location.assign(href));
92
+
93
+ const { data: film, isLoading } = useFilm(slug, { initialFilm, accountId: user?.id });
94
+ const flow = useFilmRentalFlow({ film, finalizeRentalId, loginPath });
95
+ const renderPayment = usePaymentRenderer();
96
+
97
+ /** One live rental per title, so this one row answers the CTA and every episode row. */
98
+ const rental = flow.viewerRental;
99
+ const seasons = useMemo(() => groupFilmEpisodesBySeason(film?.episodes ?? []), [film?.episodes]);
100
+
101
+ if (!film && isLoading) return <Loading fullPage />;
102
+
103
+ const panel: CSSProperties = {
104
+ padding: 20,
105
+ borderRadius: tokens.cornerRadius,
106
+ border: `1px solid ${tokens.border}`,
107
+ background: tokens.surface,
108
+ };
109
+
110
+ if (!film) {
111
+ return (
112
+ <div className={className} style={{ ...panel, maxWidth: 520, margin: "0 auto", textAlign: "center", ...style }}>
113
+ {/* A draft, an archived title, an unknown slug, another tenant's slug and
114
+ a switched-off feature are ONE answer on purpose: a storefront must
115
+ not be able to tell "not yours" from "does not exist". So there is
116
+ one sentence here, and `isError` does not change it. */}
117
+ <p style={{ color: tokens.muted, fontSize: 14 }}>{t("forge.film_detail.not_found")}</p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The offers are drawn only when the SERVER says the title is sellable.
124
+ *
125
+ * `flow.options` reads the live windows off the payload, and they survive
126
+ * every episode of the title failing transcode or being archived: `films`
127
+ * carries no rollup of episode readiness by design. `canRentTitle` is the
128
+ * server's answer to the same question `POST .../rentals` enforces, so the
129
+ * button and the endpoint agree instead of the button minting a charge the
130
+ * endpoint refuses.
131
+ */
132
+ const options = film.canRentTitle === false ? [] : flow.options;
133
+ const chosen = options.length > 0 ? flow.selectedOption : null;
134
+ const watchHref = (episodeId: string) => `${basePath}/${film.slug}/watch/${episodeId}`;
135
+
136
+ const primaryButton: CSSProperties = {
137
+ padding: "12px 20px",
138
+ borderRadius: tokens.cornerRadius,
139
+ border: "none",
140
+ background: tokens.primary,
141
+ color: tokens.textPrimary,
142
+ fontSize: 15,
143
+ fontWeight: 700,
144
+ cursor: "pointer",
145
+ fontFamily: tokens.fontFamily,
146
+ };
147
+
148
+ const secondaryButton: CSSProperties = {
149
+ padding: "8px 14px",
150
+ borderRadius: tokens.cornerRadius,
151
+ border: `1px solid ${tokens.primary}`,
152
+ background: "transparent",
153
+ color: tokens.primary,
154
+ fontSize: 13,
155
+ fontWeight: 600,
156
+ cursor: "pointer",
157
+ fontFamily: tokens.fontFamily,
158
+ };
159
+
160
+ return (
161
+ <div
162
+ className={className}
163
+ style={{
164
+ display: "flex",
165
+ flexDirection: "column",
166
+ gap: 28,
167
+ maxWidth: 1100,
168
+ margin: "0 auto",
169
+ color: tokens.text,
170
+ fontFamily: tokens.fontFamily,
171
+ ...style,
172
+ }}
173
+ >
174
+ {/* `align-items` defaults to `stretch`, which made the poster grow to match
175
+ whatever the column beside it was doing. Pinning it to the top is the
176
+ fix at the root: the checkout moving into a dialog stops the worst of
177
+ it, but any long description would have stretched the artwork too. */}
178
+ <div style={{ display: "flex", gap: 28, flexWrap: "wrap", alignItems: "flex-start" }}>
179
+ {film.cover?.url && (
180
+ <img
181
+ src={film.cover.url}
182
+ alt=""
183
+ style={{
184
+ flex: "0 1 260px",
185
+ maxWidth: 260,
186
+ aspectRatio: "2/3",
187
+ objectFit: "cover",
188
+ borderRadius: tokens.cornerRadius,
189
+ // The one accepted literal: a letterbox behind artwork.
190
+ background: "#000",
191
+ border: `1px solid ${tokens.border}`,
192
+ }}
193
+ />
194
+ )}
195
+
196
+ <div style={{ flex: "1 1 380px", minWidth: 280, display: "flex", flexDirection: "column", gap: 14 }}>
197
+ <h1
198
+ style={{
199
+ fontSize: 30,
200
+ fontWeight: 800,
201
+ lineHeight: 1.15,
202
+ fontFamily: tokens.headingFontFamily || tokens.fontFamily,
203
+ }}
204
+ >
205
+ {film.title}
206
+ </h1>
207
+
208
+ {film.description && <p style={{ fontSize: 15, lineHeight: 1.7, color: tokens.muted }}>{film.description}</p>}
209
+
210
+ {/* State the fan must act on, so it stays visible rather than behind a `?`. */}
211
+ {rental && <RentalStatusBanner rental={rental} t={t} />}
212
+
213
+ {flow.error && (
214
+ <p role="alert" style={{ fontSize: 13, color: tokens.text, ...panel, padding: 12 }}>
215
+ {flow.error}
216
+ </p>
217
+ )}
218
+
219
+ {/* Already rented: the only useful control is a way in. */}
220
+ {rental?.isLive && (
221
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
222
+ <button
223
+ type="button"
224
+ style={primaryButton}
225
+ onClick={() => {
226
+ // A film has one hidden episode; a series starts at season 1,
227
+ // episode 1 of what is actually out, hence the grouped list
228
+ // rather than the payload's own order.
229
+ const target = film.primaryEpisodeId ?? seasons[0]?.episodes[0]?.id;
230
+ if (target) go(watchHref(target));
231
+ }}
232
+ >
233
+ {t("forge.film_detail.watch_now")}
234
+ </button>
235
+ </div>
236
+ )}
237
+
238
+ {/* Not rented (or the window has run out): the offers. Hidden while a
239
+ charge is live, so a fan who closed the Paystack modal is offered
240
+ the way BACK into that charge rather than a button that would mint
241
+ a second one. */}
242
+ {!rental?.isLive && !flow.isFinalizing && flow.step !== "payment" && !flow.canOpenPaystack && (
243
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
244
+ {chosen ? (
245
+ <>
246
+ {/* Drawn ONLY for a real choice. One window needs no picker,
247
+ and its terms are already on the button below. */}
248
+ {options.length > 1 && (
249
+ <WindowPicker
250
+ t={t}
251
+ options={options}
252
+ currency={film.currency}
253
+ selectedId={chosen.id}
254
+ onSelect={flow.selectOption}
255
+ />
256
+ )}
257
+
258
+ {/* The ONE purchase CTA. On a series it buys every episode,
259
+ and it names the window it is buying: a fan who finds out
260
+ the length at expiry has been mis-sold. */}
261
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
262
+ <button
263
+ type="button"
264
+ style={primaryButton}
265
+ disabled={flow.isCreating || flow.isStartingPayment}
266
+ onClick={() => void flow.rent(chosen.id)}
267
+ >
268
+ {t(
269
+ film.kind === "series" ? "forge.film_detail.rent_series_cta" : "forge.film_detail.rent_film_cta",
270
+ {
271
+ price: formatCurrency(chosen.priceCents / 100, film.currency),
272
+ window: rentalWindowLabel(t, chosen.durationHours),
273
+ },
274
+ )}
275
+ </button>
276
+ <ClockHelp t={t} kind={film.kind} />
277
+ </div>
278
+ </>
279
+ ) : (
280
+ <p style={{ fontSize: 13, color: tokens.muted }}>{t("forge.film_detail.not_on_sale")}</p>
281
+ )}
282
+
283
+ {isInitialized && !flow.isAuthenticated && (
284
+ <p style={{ fontSize: 13, color: tokens.muted }}>
285
+ {t("forge.film_detail.sign_in_lead")}{" "}
286
+ <a
287
+ href={filmSignInHref(loginPath, typeof window === "undefined" ? "" : window.location.pathname)}
288
+ style={{ color: tokens.primary, fontWeight: 600 }}
289
+ >
290
+ {t("forge.film_detail.sign_in_action")}
291
+ </a>
292
+ </p>
293
+ )}
294
+ </div>
295
+ )}
296
+
297
+ {/* Stripe needs a card form; Paystack drives its own modal already.
298
+ The card form lives in a DIALOG rather than in this column: drawn
299
+ inline it pushed the page down and dragged the artwork with it, and
300
+ a card form is a modal task anyway - the fan is finishing one thing
301
+ and everything behind it is context, not choice.
302
+
303
+ `onOpenChange` routes a dismissal through `flow.reset`, so closing
304
+ the dialog abandons the charge exactly as the old cancel button did.
305
+ Radix gives the escape key and the backdrop click the same meaning
306
+ for free. */}
307
+ <DialogRoot
308
+ open={flow.step === "payment" && !!flow.clientSecret && !!flow.returnUrl}
309
+ onOpenChange={(next) => {
310
+ if (!next) flow.reset();
311
+ }}
312
+ >
313
+ <DialogPortal>
314
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
315
+ <DialogContent
316
+ style={{
317
+ position: "fixed",
318
+ top: "50%",
319
+ left: "50%",
320
+ transform: "translate(-50%, -50%)",
321
+ width: "min(520px, calc(100vw - 24px))",
322
+ maxHeight: "88vh",
323
+ overflowY: "auto",
324
+ background: tokens.surface,
325
+ color: tokens.text,
326
+ borderRadius: tokens.cornerRadius,
327
+ padding: 24,
328
+ zIndex: 51,
329
+ fontFamily: tokens.fontFamily,
330
+ }}
331
+ aria-describedby={undefined}
332
+ >
333
+ <div
334
+ style={{
335
+ display: "flex",
336
+ justifyContent: "space-between",
337
+ alignItems: "center",
338
+ marginBottom: 14,
339
+ gap: 12,
340
+ }}
341
+ >
342
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{film.title}</DialogTitle>
343
+ <DialogClose asChild>
344
+ <button type="button" aria-label={t("forge.film_detail.cancel_payment")} style={secondaryButton}>
345
+ {t("forge.film_detail.cancel_payment")}
346
+ </button>
347
+ </DialogClose>
348
+ </div>
349
+
350
+ {flow.clientSecret &&
351
+ flow.returnUrl &&
352
+ renderPayment({
353
+ clientSecret: flow.clientSecret,
354
+ returnUrl: flow.returnUrl,
355
+ amount: flow.chargeAmount ?? 0,
356
+ currency: flow.chargeCurrency ?? film.currency,
357
+ })}
358
+ </DialogContent>
359
+ </DialogPortal>
360
+ </DialogRoot>
361
+
362
+ {/* PAID, NOT YET RECONCILED. Without this the fan came back from the
363
+ Paystack modal to a page that still showed a Rent button while the
364
+ charge settled, so the obvious move was to reload or pay again.
365
+ The flow already exposed `isFinalizing`; nothing rendered it.
366
+
367
+ It says "do not close" because the fan cannot tell a slow
368
+ reconciliation from a hung page, and reloading mid-finalize is the
369
+ one action that makes this worse. */}
370
+ {flow.isFinalizing && !rental?.isLive && (
371
+ <div style={{ ...panel, display: "flex", alignItems: "center", gap: 12 }}>
372
+ <Loading />
373
+ <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
374
+ <span style={{ fontSize: 14, fontWeight: 600 }}>{t("forge.film_detail.confirming_title")}</span>
375
+ <span style={{ fontSize: 13, color: tokens.muted }}>{t("forge.film_detail.confirming_body")}</span>
376
+ </div>
377
+ </div>
378
+ )}
379
+
380
+ {flow.isPaystack && flow.canOpenPaystack && !flow.isFinalizing && flow.step !== "rented" && (
381
+ <PaystackPayButton
382
+ onPay={() => flow.openPaystackCheckout()}
383
+ amount={flow.chargeAmount}
384
+ currency={flow.chargeCurrency ?? film.currency}
385
+ />
386
+ )}
387
+ </div>
388
+ </div>
389
+
390
+ {film.trailer?.url && (
391
+ <section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
392
+ <h2 style={{ fontSize: 18, fontWeight: 700 }}>{t("forge.film_detail.trailer")}</h2>
393
+ {/* Ungated, public-tier MP4 - no session, no key, no HLS engine needed. */}
394
+ <video
395
+ src={film.trailer.url}
396
+ controls
397
+ playsInline
398
+ preload="none"
399
+ poster={film.cover?.url}
400
+ style={{
401
+ width: "100%",
402
+ maxWidth: 860,
403
+ aspectRatio: "16/9",
404
+ // The one accepted literal: the letterbox behind a video.
405
+ background: "#000",
406
+ borderRadius: tokens.cornerRadius,
407
+ }}
408
+ />
409
+ </section>
410
+ )}
411
+
412
+ {film.kind === "series" && seasons.length > 0 && (
413
+ <section style={{ display: "flex", flexDirection: "column", gap: 18 }}>
414
+ <h2 style={{ fontSize: 18, fontWeight: 700 }}>{t("forge.film_detail.episodes")}</h2>
415
+ {seasons.map((season) => (
416
+ <div key={season.seasonNumber} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
417
+ {seasons.length > 1 && (
418
+ <h3 style={{ fontSize: 14, fontWeight: 700, color: tokens.muted }}>
419
+ {t("forge.film_detail.season", { number: season.seasonNumber })}
420
+ </h3>
421
+ )}
422
+ {season.episodes.map((episode) => (
423
+ <EpisodeRow
424
+ key={episode.id}
425
+ episode={episode}
426
+ t={t}
427
+ // Nothing is sold at this level. One live rental on the title
428
+ // covers every row, so this is the same question for all of
429
+ // them and it is asked of the same single row.
430
+ canWatch={canWatchFilmEpisode(rental, episode)}
431
+ onWatch={() => go(watchHref(episode.id))}
432
+ />
433
+ ))}
434
+ </div>
435
+ ))}
436
+ </section>
437
+ )}
438
+ </div>
439
+ );
440
+ }
441
+
442
+ /**
443
+ * The windows this title is sold in, in the creator's own order.
444
+ *
445
+ * Radios rather than buttons, and that is the whole point: each row is a price
446
+ * AND a length, both visible at once, and picking one does not buy anything. The
447
+ * fan reads the choice, makes it, and then presses a CTA that repeats what they
448
+ * chose. A row of "Rent" buttons would take money on the click that was meant to
449
+ * compare.
450
+ *
451
+ * Only ever drawn for two or more options - see the block docblock.
452
+ */
453
+ function WindowPicker({
454
+ t,
455
+ options,
456
+ currency,
457
+ selectedId,
458
+ onSelect,
459
+ }: {
460
+ t: ForgeT;
461
+ options: PublicFilmRentalOption[];
462
+ currency: string;
463
+ selectedId: string;
464
+ onSelect: (id: string) => void;
465
+ }) {
466
+ const tokens = useThemeTokens();
467
+ const { formatCurrency } = useFormatCurrency();
468
+
469
+ return (
470
+ <fieldset style={{ border: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 8 }}>
471
+ <legend style={{ fontSize: 13, fontWeight: 700, padding: 0, marginBottom: 4 }}>
472
+ {t("forge.film_detail.choose_window")}
473
+ </legend>
474
+ {options.map((option) => {
475
+ const isSelected = option.id === selectedId;
476
+ return (
477
+ <label
478
+ key={option.id}
479
+ style={{
480
+ display: "flex",
481
+ alignItems: "center",
482
+ gap: 10,
483
+ padding: "10px 14px",
484
+ borderRadius: tokens.cornerRadius,
485
+ border: `1px solid ${isSelected ? tokens.primary : tokens.border}`,
486
+ background: isSelected ? `${tokens.primary}12` : tokens.surface,
487
+ cursor: "pointer",
488
+ }}
489
+ >
490
+ <input
491
+ type="radio"
492
+ name="film-rental-window"
493
+ value={option.id}
494
+ checked={isSelected}
495
+ onChange={() => onSelect(option.id)}
496
+ style={{ accentColor: tokens.primary, cursor: "pointer" }}
497
+ />
498
+ <span style={{ flex: "1 1 auto", minWidth: 0, fontSize: 14, fontWeight: isSelected ? 700 : 500 }}>
499
+ {/* A creator's own name for the window when they gave it one
500
+ ("Weekend pass"), and the length itself when they did not. The
501
+ length is repeated on the right either way, so a named window
502
+ never hides how long it runs. */}
503
+ {option.label || rentalWindowLabel(t, option.durationHours)}
504
+ </span>
505
+ <span style={{ fontSize: 13, color: tokens.muted }}>{rentalWindowLabel(t, option.durationHours)}</span>
506
+ <span style={{ fontSize: 14, fontWeight: 700 }}>
507
+ {formatCurrency(option.priceCents / 100, currency)}
508
+ </span>
509
+ </label>
510
+ );
511
+ })}
512
+ </fieldset>
513
+ );
514
+ }
515
+
516
+ /**
517
+ * The one sentence a fan must read every time: what they own and how long it
518
+ * lasts. State, not help, so it stays visible.
519
+ */
520
+ function RentalStatusBanner({
521
+ rental,
522
+ t,
523
+ }: {
524
+ rental: Pick<PublicFilmRental, "isLive" | "msRemaining" | "startBy">;
525
+ t: ForgeT;
526
+ }) {
527
+ const tokens = useThemeTokens();
528
+ const remaining = splitRemainingMs(rental.msRemaining);
529
+
530
+ const body = !rental.isLive
531
+ ? t("forge.film_detail.rental_expired")
532
+ : rental.msRemaining == null
533
+ ? t("forge.film_detail.rental_not_started")
534
+ : remaining && remaining.days > 0
535
+ ? t("forge.film_detail.rental_remaining_days", { days: remaining.days, hours: remaining.hours })
536
+ : remaining && remaining.hours > 0
537
+ ? t("forge.film_detail.rental_remaining_hours", { hours: remaining.hours, minutes: remaining.minutes })
538
+ : t("forge.film_detail.rental_remaining_minutes", { minutes: remaining?.minutes ?? 0 });
539
+
540
+ return (
541
+ <div
542
+ style={{
543
+ padding: "10px 14px",
544
+ borderRadius: tokens.cornerRadius,
545
+ border: `1px solid ${tokens.border}`,
546
+ background: `${tokens.primary}12`,
547
+ fontSize: 13,
548
+ lineHeight: 1.6,
549
+ }}
550
+ >
551
+ <strong style={{ fontWeight: 700 }}>{t("forge.film_detail.rental_owned")}</strong> {body}
552
+ </div>
553
+ );
554
+ }
555
+
556
+ /**
557
+ * One episode: what it is, how long it runs, and whether this viewer can open it.
558
+ *
559
+ * It carries no price and no Rent button. Episodes are not sold, so the only two
560
+ * end states are a Watch button and nothing at all, and "nothing at all" is the
561
+ * honest answer for a fan who has not rented the title: the way in is the one
562
+ * CTA at the top of the page.
563
+ */
564
+ function EpisodeRow({
565
+ episode,
566
+ t,
567
+ canWatch,
568
+ onWatch,
569
+ }: {
570
+ episode: PublicFilmEpisode;
571
+ t: ForgeT;
572
+ canWatch: boolean;
573
+ onWatch: () => void;
574
+ }) {
575
+ const tokens = useThemeTokens();
576
+ const runtime = splitDurationSec(episode.durationSec);
577
+ const released = isFilmEpisodeReleased(episode);
578
+
579
+ return (
580
+ <div
581
+ style={{
582
+ display: "flex",
583
+ alignItems: "center",
584
+ gap: 12,
585
+ flexWrap: "wrap",
586
+ padding: "10px 14px",
587
+ borderRadius: tokens.cornerRadius,
588
+ border: `1px solid ${tokens.border}`,
589
+ background: tokens.surface,
590
+ }}
591
+ >
592
+ <span style={{ fontSize: 13, fontWeight: 700, color: tokens.muted, minWidth: 34 }}>{episode.episodeNumber}</span>
593
+ <div style={{ flex: "1 1 200px", minWidth: 0 }}>
594
+ <div style={{ fontSize: 14, fontWeight: 600 }}>
595
+ {episode.title || t("forge.film_detail.untitled_episode", { number: episode.episodeNumber })}
596
+ </div>
597
+ <div style={{ fontSize: 12, color: tokens.muted, display: "flex", gap: 10, flexWrap: "wrap" }}>
598
+ {runtime && (
599
+ <span>
600
+ {runtime.hours > 0
601
+ ? t("forge.film_detail.runtime_hm", { hours: runtime.hours, minutes: runtime.minutes })
602
+ : t("forge.film_detail.runtime_m", { minutes: runtime.minutes })}
603
+ </span>
604
+ )}
605
+ {/* A free episode is not a per-episode rental. It is one episode the
606
+ creator opened up, and it plays with no rental at all. */}
607
+ {episode.isFreePreview && (
608
+ <span style={{ color: tokens.primary, fontWeight: 700 }}>{t("forge.film_detail.free_preview")}</span>
609
+ )}
610
+ {/* Drip release. The API omits an unreleased episode from the public
611
+ payload, so this shows on a page cached across its own release
612
+ moment - which is exactly when a Watch button would 404. */}
613
+ {!released && episode.availableAt && (
614
+ <span>{t("forge.film_detail.episode_unreleased", { date: formatReleaseDate(episode.availableAt) })}</span>
615
+ )}
616
+ </div>
617
+ </div>
618
+
619
+ {canWatch && (
620
+ <button
621
+ type="button"
622
+ onClick={onWatch}
623
+ style={{
624
+ padding: "8px 14px",
625
+ borderRadius: tokens.cornerRadius,
626
+ border: "none",
627
+ background: tokens.primary,
628
+ color: tokens.textPrimary,
629
+ fontSize: 13,
630
+ fontWeight: 700,
631
+ cursor: "pointer",
632
+ }}
633
+ >
634
+ {episode.isFreePreview ? t("forge.film_detail.watch_free") : t("forge.film_detail.watch")}
635
+ </button>
636
+ )}
637
+ </div>
638
+ );
639
+ }
640
+
641
+ /** A release date in the reader's own locale. Blank rather than "Invalid Date". */
642
+ function formatReleaseDate(value: string): string {
643
+ const at = new Date(value);
644
+ return Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString(undefined, { month: "short", day: "numeric" });
645
+ }
646
+
647
+ /**
648
+ * When the clock starts, and why it is one clock.
649
+ *
650
+ * Behind a `?` because somebody who has rented a film before would skip it every
651
+ * time. The WINDOW itself is not in here - that is on the button.
652
+ */
653
+ function ClockHelp({ t, kind }: { t: ForgeT; kind: "film" | "series" }) {
654
+ return (
655
+ <CallHelpHint label={t("forge.film_detail.clock_help_label")}>
656
+ {kind === "series" ? t("forge.film_detail.clock_help_series") : t("forge.film_detail.clock_help_film")}
657
+ </CallHelpHint>
658
+ );
659
+ }
660
+
661
+ export default FilmDetail;