@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.
- package/package.json +3 -2
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/i18n/de.json +128 -28
- package/src/i18n/en.json +128 -28
- 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 +18 -16
- package/src/ui/media/CallStage.tsx +59 -1
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +28 -36
- 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 +28 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- 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
|
+
});
|
package/src/ui/media/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
*
|
|
1107
|
-
*
|
|
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
|
-
<
|
|
1165
|
+
<a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
|
|
1174
1166
|
{tr("forge.account_dashboard.booking_join_video")}
|
|
1175
|
-
</
|
|
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;
|