@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.
Files changed (40) 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/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
  5. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  6. package/src/data/queries/useFilms.ts +561 -0
  7. package/src/data/queries/usePaymentFlow.ts +28 -3
  8. package/src/i18n/de.json +130 -28
  9. package/src/i18n/en.json +130 -28
  10. package/src/index.ts +5 -0
  11. package/src/provider/ForgeAppProvider.tsx +10 -0
  12. package/src/provider/ForgeProvider.tsx +18 -3
  13. package/src/server/index.ts +76 -17
  14. package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
  15. package/src/ui/headless/dialog.tsx +139 -4
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +21 -16
  26. package/src/ui/media/CallStage.tsx +59 -1
  27. package/src/ui/media/CallWindowNotice.tsx +94 -0
  28. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  29. package/src/ui/media/index.ts +9 -0
  30. package/src/ui/styled/AccountDashboard.tsx +101 -37
  31. package/src/ui/styled/FilmCatalog.tsx +278 -0
  32. package/src/ui/styled/FilmDetail.tsx +661 -0
  33. package/src/ui/styled/FilmLibrary.tsx +291 -0
  34. package/src/ui/styled/FilmWatch.tsx +701 -0
  35. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  36. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
  37. package/src/ui/styled/forge-utilities.css +6 -0
  38. package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
  39. package/src/utils/paystackCheckout.ts +47 -0
  40. 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;