@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,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;
|