@tribe-nest/forge 3.34.0 → 3.36.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/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +130 -28
- package/src/i18n/en.json +130 -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/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- 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 +21 -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 +101 -37
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +291 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/forge-utilities.css +6 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -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;
|