@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.
Files changed (33) hide show
  1. package/package.json +3 -2
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  5. package/src/data/queries/useFilms.ts +561 -0
  6. package/src/i18n/de.json +128 -28
  7. package/src/i18n/en.json +128 -28
  8. package/src/index.ts +5 -0
  9. package/src/provider/ForgeAppProvider.tsx +10 -0
  10. package/src/provider/ForgeProvider.tsx +18 -3
  11. package/src/server/index.ts +76 -17
  12. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  13. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  14. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  15. package/src/ui/headless/film/index.ts +36 -0
  16. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  17. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  18. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  19. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  20. package/src/ui/headless/index.ts +6 -9
  21. package/src/ui/index.ts +18 -16
  22. package/src/ui/media/CallStage.tsx +59 -1
  23. package/src/ui/media/CallWindowNotice.tsx +94 -0
  24. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  25. package/src/ui/media/index.ts +9 -0
  26. package/src/ui/styled/AccountDashboard.tsx +28 -36
  27. package/src/ui/styled/FilmCatalog.tsx +278 -0
  28. package/src/ui/styled/FilmDetail.tsx +661 -0
  29. package/src/ui/styled/FilmLibrary.tsx +254 -0
  30. package/src/ui/styled/FilmWatch.tsx +701 -0
  31. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  32. package/src/ui/styled/forge-utilities.css +3 -0
  33. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,94 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useForgeT } from "../../i18n";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+
6
+ /**
7
+ * What a call page shows when there is no call to join yet, or any more.
8
+ *
9
+ * ## Why this is not the connection status
10
+ *
11
+ * A page that mounts `<CallStage>` regardless of the window tries to connect,
12
+ * fails, and reports the only thing it knows: "not yet connected". That is true
13
+ * and useless. Somebody who followed a link from their confirmation email is
14
+ * asking one of two questions, and the server already answered both in the same
15
+ * read that produced this page: WHEN can I come in, and HAVE I missed it.
16
+ *
17
+ * ## Why the times are rendered, not the raw window
18
+ *
19
+ * `opensAt` is the server's own boundary, the same one it enforces when it
20
+ * refuses a ticket, so what this prints is what the server will honour rather
21
+ * than a client-side guess. It is formatted in the reader's locale and, where
22
+ * the session carries one, the session's timezone: a coaching client in Lagos
23
+ * looking at a Berlin coach's session should be told the time they will
24
+ * actually be sitting at their desk.
25
+ *
26
+ * Deliberately NOT a countdown. A ticking clock invites somebody to sit and
27
+ * watch it, and the page is already re-fetched when they return.
28
+ */
29
+ export type CallWindowNoticeProps = {
30
+ /** The earliest the server will mint a ticket. */
31
+ opensAt: string | Date;
32
+ /** After this it refuses new tickets; anyone already inside stays inside. */
33
+ closesAt: string | Date;
34
+ /** The call's own title, so the reader knows which session this is. */
35
+ title?: string;
36
+ /** IANA zone the session was sold in, when there is one. */
37
+ timeZone?: string | null;
38
+ /** Injected by specs; defaults to the clock. */
39
+ now?: Date;
40
+ /** A way back, rendered under the message when given. */
41
+ action?: ReactNode;
42
+ };
43
+
44
+ export function CallWindowNotice({ opensAt, closesAt, title, timeZone, now, action }: CallWindowNoticeProps) {
45
+ const t = useForgeT();
46
+ const tokens = useThemeTokens();
47
+
48
+ const opens = new Date(opensAt);
49
+ const closes = new Date(closesAt);
50
+ const at = now ?? new Date();
51
+
52
+ const when = opens.toLocaleString(undefined, {
53
+ weekday: "short",
54
+ day: "numeric",
55
+ month: "short",
56
+ hour: "numeric",
57
+ minute: "2-digit",
58
+ timeZone: timeZone ?? undefined,
59
+ });
60
+
61
+ // Three states, and the middle one is the reason this component exists: a
62
+ // call that is open but unreachable is the connection status's business, not
63
+ // this component's, so it renders nothing and lets `<CallStage>` speak.
64
+ const tooEarly = at < opens;
65
+ const tooLate = at > closes;
66
+ if (!tooEarly && !tooLate) return null;
67
+
68
+ return (
69
+ <div
70
+ style={{
71
+ height: "100%",
72
+ display: "flex",
73
+ flexDirection: "column",
74
+ alignItems: "center",
75
+ justifyContent: "center",
76
+ gap: "0.5rem",
77
+ padding: "2rem 1.5rem",
78
+ textAlign: "center",
79
+ color: tokens.text,
80
+ backgroundColor: tokens.background,
81
+ borderRadius: tokens.cornerRadius,
82
+ }}
83
+ >
84
+ {title && <div style={{ fontWeight: 600, fontSize: "1.125rem" }}>{title}</div>}
85
+ <div style={{ fontSize: "1rem" }}>
86
+ {tooEarly ? t("forge.call_window.not_started") : t("forge.call_window.ended")}
87
+ </div>
88
+ <div style={{ fontSize: "0.875rem", opacity: 0.7, maxWidth: "32rem" }}>
89
+ {tooEarly ? t("forge.call_window.opens_at", { datetime: when }) : t("forge.call_window.ended_detail")}
90
+ </div>
91
+ {action && <div style={{ marginTop: "0.75rem" }}>{action}</div>}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+
4
+ import { ForgeI18nProvider } from "../../../i18n";
5
+ import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
6
+ import { CallWindowNotice } from "../CallWindowNotice";
7
+
8
+ /**
9
+ * The state a call page is in for most of a session's life: booked, not yet
10
+ * open, or long over.
11
+ *
12
+ * Every surface used to mount the call regardless and report "not yet
13
+ * connected", which is the connection's answer to a question nobody asked. The
14
+ * two questions somebody following a link from their confirmation email is
15
+ * actually asking are WHEN can I come in and HAVE I missed it, and the server
16
+ * sends both boundaries with the page.
17
+ */
18
+ const theme: ForgeTheme = {
19
+ colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" },
20
+ cornerRadius: 10,
21
+ };
22
+
23
+ const MINUTE = 60_000;
24
+ const HOUR = 60 * MINUTE;
25
+
26
+ const render = (props: { opensAt: Date; closesAt: Date; now: Date; locale?: string }) =>
27
+ renderToStaticMarkup(
28
+ <ForgeI18nProvider locale={props.locale ?? "en"}>
29
+ <ForgeThemeProvider theme={theme}>
30
+ <CallWindowNotice
31
+ title="1:1 Coaching"
32
+ opensAt={props.opensAt.toISOString()}
33
+ closesAt={props.closesAt.toISOString()}
34
+ now={props.now}
35
+ />
36
+ </ForgeThemeProvider>
37
+ </ForgeI18nProvider>,
38
+ );
39
+
40
+ describe("CallWindowNotice", () => {
41
+ const now = new Date("2026-08-23T12:00:00.000Z");
42
+
43
+ it("says the call has not started, and when to come back", () => {
44
+ const html = render({ opensAt: new Date(now.getTime() + 6 * HOUR), closesAt: new Date(now.getTime() + 9 * HOUR), now });
45
+
46
+ expect(html).toContain("has not started yet");
47
+ expect(html).toContain("You can join from");
48
+ // The session it belongs to, so a reader with two bookings knows which.
49
+ expect(html).toContain("1:1 Coaching");
50
+ });
51
+
52
+ it("says the call has ended once the window is past", () => {
53
+ const html = render({ opensAt: new Date(now.getTime() - 4 * HOUR), closesAt: new Date(now.getTime() - HOUR), now });
54
+
55
+ expect(html).toContain("has ended");
56
+ expect(html).not.toContain("You can join from");
57
+ });
58
+
59
+ it("renders NOTHING while the room is open, so the call speaks for itself", () => {
60
+ // The one state this component must stay out of: a call that is open but
61
+ // unreachable is the connection status's business.
62
+ const html = render({ opensAt: new Date(now.getTime() - MINUTE), closesAt: new Date(now.getTime() + HOUR), now });
63
+
64
+ // The provider still emits its own <style> tag, so this asserts the absence
65
+ // of the NOTICE rather than of all output.
66
+ expect(html).not.toContain("has not started");
67
+ expect(html).not.toContain("has ended");
68
+ expect(html).not.toContain("1:1 Coaching");
69
+ });
70
+
71
+ it("speaks the visitor's language", () => {
72
+ const html = render({
73
+ opensAt: new Date(now.getTime() + 6 * HOUR),
74
+ closesAt: new Date(now.getTime() + 9 * HOUR),
75
+ now,
76
+ locale: "de",
77
+ });
78
+
79
+ // The reported symptom was a German visitor being told only "noch nicht
80
+ // verbunden". They now get the answer, in German.
81
+ expect(html).toContain("noch nicht begonnen");
82
+ });
83
+ });
@@ -93,6 +93,15 @@ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protoco
93
93
  * for both.
94
94
  */
95
95
  export { CallStage, type CallStageProps } from "./CallStage";
96
+
97
+ /**
98
+ * What a call page shows OUTSIDE the window, when there is nothing to connect
99
+ * to. Exported because all three surfaces (admin, the client portal and a
100
+ * creator site) reached the same dead end: they mounted the call regardless,
101
+ * failed to connect, and reported "not yet connected", which answers neither of
102
+ * the two questions somebody arriving from an email is asking.
103
+ */
104
+ export { CallWindowNotice, type CallWindowNoticeProps } from "./CallWindowNotice";
96
105
  export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
97
106
  export {
98
107
  callHeadCount,
@@ -1,4 +1,4 @@
1
- import { lazy, Suspense, useEffect, useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import {
3
3
  usePublicAuth,
4
4
  useUserOrders,
@@ -1082,15 +1082,6 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1082
1082
  );
1083
1083
  }
1084
1084
 
1085
- /**
1086
- * The call UI, fetched on the click. `import()` splits it (and the media SDK
1087
- * with mediasoup underneath) out of the `./ui` bundle, so a visitor who never
1088
- * joins a call never downloads it. `bookingCallWindow` is deliberately in a
1089
- * module with no such dependency, which is what lets the CONTROL be decided
1090
- * here without loading the SDK.
1091
- */
1092
- const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
1093
-
1094
1085
  /**
1095
1086
  * The buyer's way into a coaching video call, on a code website.
1096
1087
  *
@@ -1101,12 +1092,18 @@ const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
1101
1092
  * the client joins "from their account on your site", so this is where it has
1102
1093
  * to be. It renders the SAME `<CallStage>` the coach sees in admin.
1103
1094
  *
1104
- * ## Why the call is mounted only after a click
1095
+ * ## Why this is a LINK and not the call
1096
+ *
1097
+ * It used to mount the room inline, between two rows of a list, at whatever
1098
+ * width the portal happened to be. A call has its own full-screen page now
1099
+ * (`/i/video-calls/:callId`) and it is the SAME address the coach opens from
1100
+ * the dashboard, so both sides of a session meet in the same place instead of
1101
+ * in two different-looking products. Linking also keeps the media SDK out of
1102
+ * this bundle without needing a lazy boundary here: the page that needs
1103
+ * mediasoup is the only thing that loads it.
1105
1104
  *
1106
- * The provider connects on mount and closes on unmount. Rendering it for every
1107
- * row in a list of sessions would open a media connection per row, against
1108
- * rooms the server would then have to provision. So it lives behind Join, and
1109
- * leaving unmounts it.
1105
+ * A plain anchor rather than a router `Link`, because this component ships to
1106
+ * creator sites and must not assume which router mounted it.
1110
1107
  *
1111
1108
  * ## Why the window ticks
1112
1109
  *
@@ -1119,7 +1116,6 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1119
1116
  const { button } = useCardStyles();
1120
1117
  const tr = useForgeT();
1121
1118
  const [now, setNow] = useState(() => new Date());
1122
- const [joined, setJoined] = useState(false);
1123
1119
 
1124
1120
  useEffect(() => {
1125
1121
  const timer = setInterval(() => setNow(new Date()), 30_000);
@@ -1129,24 +1125,6 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1129
1125
  const call = bookingCallWindow(booking, now);
1130
1126
  if (!call.isVideo) return null;
1131
1127
 
1132
- if (joined) {
1133
- // The provider's lifetime is exactly the call's: unmounting this subtree is
1134
- // what closes the socket, stops the local tracks and puts the camera light
1135
- // out. Leaving therefore goes through state, not through a `close()` that
1136
- // leaves a live provider mounted to reconnect on its next render.
1137
- return (
1138
- <div style={{ marginTop: 12 }}>
1139
- <Suspense fallback={<Loading />}>
1140
- <LazyBookingCallScreen
1141
- bookingId={booking.id}
1142
- title={booking.coachingProductTitle}
1143
- onLeave={() => setJoined(false)}
1144
- />
1145
- </Suspense>
1146
- </div>
1147
- );
1148
- }
1149
-
1150
1128
  if (!call.isOpen) {
1151
1129
  // State the buyer has to act on, so it is content and stays visible: the
1152
1130
  // room is not open yet, and this is when to come back.
@@ -1168,11 +1146,25 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1168
1146
  );
1169
1147
  }
1170
1148
 
1149
+ /**
1150
+ * Open, but no address: nothing to link to, so draw nothing.
1151
+ *
1152
+ * Deliberately AFTER the window branch, so a session that has not opened yet
1153
+ * still says when it does. That copy is about the SESSION and must not
1154
+ * depend on whether a room record exists.
1155
+ *
1156
+ * This is reachable only for a booking confirmed before calls became
1157
+ * addressable, since a confirmation now writes the record. The row above
1158
+ * still says the session is a video call, so the buyer is not left thinking
1159
+ * it is somewhere else; a dead button would be worse than none.
1160
+ */
1161
+ if (!booking.videoCallId) return null;
1162
+
1171
1163
  return (
1172
1164
  <div style={{ marginTop: 12 }}>
1173
- <button type="button" onClick={() => setJoined(true)} style={button}>
1165
+ <a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
1174
1166
  {tr("forge.account_dashboard.booking_join_video")}
1175
- </button>
1167
+ </a>
1176
1168
  </div>
1177
1169
  );
1178
1170
  }
@@ -0,0 +1,278 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { useFormatCurrency } from "../format/useFormatCurrency";
4
+ import { useFilmCatalog, type UseFilmCatalogOptions } from "../headless/film/useFilmCatalog";
5
+ import { filmFromPriceCents, splitRentalHours, type PublicFilmSummary } from "../../data/queries/useFilms";
6
+ import { Loading } from "./Loading";
7
+ import { useForgeT, type ForgeT } from "../../i18n";
8
+
9
+ export interface FilmCatalogProps extends UseFilmCatalogOptions {
10
+ /** Where a title's detail page lives. Default `/i/films`. */
11
+ basePath?: string;
12
+ /** SPA navigation adapter. Defaults to a full-page visit. */
13
+ navigate?: (href: string) => void;
14
+ /** Draw the search box and the film/series filter. Default true. */
15
+ showFilters?: boolean;
16
+ className?: string;
17
+ style?: CSSProperties;
18
+ }
19
+
20
+ /**
21
+ * The public film and series catalogue.
22
+ *
23
+ * The state that trips people up is the third one. There is "loading", there is
24
+ * "this creator has no titles", and there is "film rentals are not switched on
25
+ * here" - and the last two are NOT the same sentence. `/i/films` ships to every
26
+ * site that takes a starter update, so a site whose owner has never heard of the
27
+ * feature will render this page; telling that owner their catalogue is empty
28
+ * sends them looking for a title they never created.
29
+ */
30
+ export function FilmCatalog({
31
+ basePath = "/i/films",
32
+ navigate,
33
+ showFilters = true,
34
+ className,
35
+ style,
36
+ ...catalogOptions
37
+ }: FilmCatalogProps) {
38
+ const t = useForgeT();
39
+ const tokens = useThemeTokens();
40
+ const catalog = useFilmCatalog(catalogOptions);
41
+ const go = navigate ?? ((href: string) => window.location.assign(href));
42
+
43
+ const notice: CSSProperties = {
44
+ padding: "28px 20px",
45
+ borderRadius: tokens.cornerRadius,
46
+ border: `1px solid ${tokens.border}`,
47
+ background: tokens.surface,
48
+ color: tokens.muted,
49
+ textAlign: "center",
50
+ fontSize: 14,
51
+ };
52
+
53
+ const control: CSSProperties = {
54
+ padding: "10px 12px",
55
+ borderRadius: tokens.cornerRadius,
56
+ border: `1px solid ${tokens.border}`,
57
+ background: tokens.background,
58
+ color: tokens.text,
59
+ fontFamily: tokens.fontFamily,
60
+ fontSize: 14,
61
+ };
62
+
63
+ if (catalog.isLoading) return <Loading fullPage />;
64
+
65
+ return (
66
+ <div
67
+ className={className}
68
+ style={{
69
+ display: "flex",
70
+ flexDirection: "column",
71
+ gap: 20,
72
+ maxWidth: 1200,
73
+ margin: "0 auto",
74
+ color: tokens.text,
75
+ fontFamily: tokens.fontFamily,
76
+ ...style,
77
+ }}
78
+ >
79
+ {showFilters && catalog.enabled && (
80
+ <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
81
+ <input
82
+ type="search"
83
+ value={catalog.query}
84
+ onChange={(e) => catalog.setQuery(e.target.value)}
85
+ placeholder={t("forge.film_catalog.search_placeholder")}
86
+ aria-label={t("forge.film_catalog.search_placeholder")}
87
+ style={{ ...control, flex: "1 1 220px", minWidth: 0 }}
88
+ />
89
+ <select
90
+ value={catalog.kind ?? ""}
91
+ onChange={(e) => catalog.setKind((e.target.value || undefined) as "film" | "series" | undefined)}
92
+ aria-label={t("forge.film_catalog.filter_kind")}
93
+ style={control}
94
+ >
95
+ <option value="">{t("forge.film_catalog.all_kinds")}</option>
96
+ <option value="film">{t("forge.film_catalog.kind_films")}</option>
97
+ <option value="series">{t("forge.film_catalog.kind_series")}</option>
98
+ </select>
99
+ </div>
100
+ )}
101
+
102
+ {catalog.isError && <p style={notice}>{t("forge.film_catalog.load_error")}</p>}
103
+
104
+ {/* Not the same as "no titles". See the component docblock. */}
105
+ {!catalog.isError && !catalog.enabled && <p style={notice}>{t("forge.film_catalog.not_enabled")}</p>}
106
+
107
+ {!catalog.isError && catalog.isEmpty && <p style={notice}>{t("forge.film_catalog.empty")}</p>}
108
+
109
+ {catalog.films.length > 0 && (
110
+ <div
111
+ style={{
112
+ display: "grid",
113
+ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
114
+ gap: 20,
115
+ }}
116
+ >
117
+ {catalog.films.map((film) => (
118
+ <FilmCard key={film.id} film={film} t={t} onOpen={() => go(`${basePath}/${film.slug}`)} />
119
+ ))}
120
+ </div>
121
+ )}
122
+
123
+ {catalog.pageCount > 1 && (
124
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 16 }}>
125
+ <button
126
+ type="button"
127
+ onClick={catalog.prevPage}
128
+ disabled={!catalog.hasPrevPage}
129
+ style={{
130
+ ...control,
131
+ cursor: catalog.hasPrevPage ? "pointer" : "default",
132
+ opacity: catalog.hasPrevPage ? 1 : 0.5,
133
+ }}
134
+ >
135
+ {t("forge.film_catalog.previous")}
136
+ </button>
137
+ <span style={{ fontSize: 13, color: tokens.muted }}>
138
+ {t("forge.film_catalog.page_of", { page: catalog.page, total: catalog.pageCount })}
139
+ </span>
140
+ <button
141
+ type="button"
142
+ onClick={catalog.nextPage}
143
+ disabled={!catalog.hasNextPage}
144
+ style={{
145
+ ...control,
146
+ cursor: catalog.hasNextPage ? "pointer" : "default",
147
+ opacity: catalog.hasNextPage ? 1 : 0.5,
148
+ }}
149
+ >
150
+ {t("forge.film_catalog.next")}
151
+ </button>
152
+ </div>
153
+ )}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ function FilmCard({ film, t, onOpen }: { film: PublicFilmSummary; t: ForgeT; onOpen: () => void }) {
159
+ const tokens = useThemeTokens();
160
+ const { formatCurrency } = useFormatCurrency();
161
+ /**
162
+ * A card prints ONE number, and for a title sold in several windows that
163
+ * number is the cheapest, said as "from".
164
+ *
165
+ * Dropping the "from" would be the mis-sell in miniature: a card reading "Rent
166
+ * $2" for a title whose 48-hour window is $5 has quoted a price the detail
167
+ * page then contradicts.
168
+ */
169
+ const priceCents = filmFromPriceCents(film);
170
+ const hasChoice = (film.rentalOptions?.length ?? 0) > 1;
171
+
172
+ return (
173
+ <button
174
+ type="button"
175
+ onClick={onOpen}
176
+ style={{
177
+ display: "flex",
178
+ flexDirection: "column",
179
+ gap: 8,
180
+ padding: 0,
181
+ border: "none",
182
+ background: "transparent",
183
+ color: tokens.text,
184
+ fontFamily: tokens.fontFamily,
185
+ textAlign: "left",
186
+ cursor: "pointer",
187
+ }}
188
+ >
189
+ <div
190
+ style={{
191
+ position: "relative",
192
+ width: "100%",
193
+ // A poster is 2:3 everywhere a poster has ever been printed; forcing
194
+ // 16:9 here is what makes a film catalogue look like a blog.
195
+ aspectRatio: "2/3",
196
+ borderRadius: tokens.cornerRadius,
197
+ overflow: "hidden",
198
+ // The one accepted literal: a letterbox behind artwork of unknown size.
199
+ background: "#000",
200
+ border: `1px solid ${tokens.border}`,
201
+ }}
202
+ >
203
+ {film.cover?.url && (
204
+ <img
205
+ src={film.cover.url}
206
+ alt=""
207
+ loading="lazy"
208
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
209
+ />
210
+ )}
211
+ <span
212
+ style={{
213
+ position: "absolute",
214
+ top: 8,
215
+ left: 8,
216
+ padding: "2px 8px",
217
+ borderRadius: 999,
218
+ fontSize: 11,
219
+ fontWeight: 700,
220
+ background: tokens.primary,
221
+ color: tokens.textPrimary,
222
+ }}
223
+ >
224
+ {film.kind === "series" ? t("forge.film_catalog.badge_series") : t("forge.film_catalog.badge_film")}
225
+ </span>
226
+ {film.hasFreePreview && (
227
+ <span
228
+ style={{
229
+ position: "absolute",
230
+ bottom: 8,
231
+ left: 8,
232
+ padding: "2px 8px",
233
+ borderRadius: 999,
234
+ fontSize: 11,
235
+ fontWeight: 700,
236
+ background: tokens.surface,
237
+ color: tokens.text,
238
+ border: `1px solid ${tokens.border}`,
239
+ }}
240
+ >
241
+ {t("forge.film_catalog.free_preview")}
242
+ </span>
243
+ )}
244
+ </div>
245
+
246
+ <span style={{ fontSize: 15, fontWeight: 700, lineHeight: 1.3 }}>{film.title}</span>
247
+
248
+ <span style={{ fontSize: 12, color: tokens.muted }}>
249
+ {priceCents == null
250
+ ? t("forge.film_catalog.not_on_sale")
251
+ : t(hasChoice ? "forge.film_catalog.rent_from_price" : "forge.film_catalog.rent_price", {
252
+ price: formatCurrency(priceCents / 100, film.currency),
253
+ })}
254
+ </span>
255
+
256
+ {film.kind === "series" && (
257
+ <span style={{ fontSize: 12, color: tokens.muted }}>
258
+ {t("forge.film_catalog.episodes_available", { count: film.availableEpisodeCount })}
259
+ </span>
260
+ )}
261
+ </button>
262
+ );
263
+ }
264
+
265
+ /**
266
+ * "48 hours" or "2 days", from a whole-hours window.
267
+ *
268
+ * Shared by every block that has to state the window before the fan pays, which
269
+ * is all of them: a fan who buys a ten-episode series on a 48-hour clock and
270
+ * discovers it at expiry has been mis-sold.
271
+ */
272
+ export function rentalWindowLabel(t: ForgeT, hours: number): string {
273
+ const { days, hours: rest } = splitRentalHours(hours);
274
+ if (days > 0 && rest === 0) return t("forge.film_detail.window_days", { days });
275
+ return t("forge.film_detail.window_hours", { hours });
276
+ }
277
+
278
+ export default FilmCatalog;