@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,291 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import { rentalWindowLabel } from "./FilmCatalog";
5
+ import { Loading } from "./Loading";
6
+ import {
7
+ splitRemainingMs,
8
+ useMyFilmRentals,
9
+ type FilmWatchProgressEntry,
10
+ type MyFilmRental,
11
+ } from "../../data/queries/useFilms";
12
+ import { useForgeT, type ForgeT } from "../../i18n";
13
+
14
+ export interface FilmRentalListProps {
15
+ /** Where films live. Default `/i/films`. */
16
+ basePath?: string;
17
+ /** SPA navigation adapter. Defaults to a full-page visit. */
18
+ navigate?: (href: string) => void;
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ }
22
+
23
+ /** The page takes exactly what the list takes: it adds a heading, nothing else. */
24
+ export type FilmLibraryProps = FilmRentalListProps;
25
+
26
+ /**
27
+ * The rentals themselves, with no page furniture around them.
28
+ *
29
+ * Split out of `FilmLibrary` so the account dashboard's Rentals tab renders the
30
+ * SAME rows as `/i/films/library` rather than a second implementation of "is
31
+ * this window still open". Two answers to that question is one answer too many:
32
+ * the divergent copy would appear on whichever surface was edited second, and
33
+ * the fan would be told two different things about the same rental.
34
+ *
35
+ * It fetches its own data. `useMyFilmRentals` is keyed on the account, so a host
36
+ * that already read it (the dashboard does, to decide whether to draw the tab at
37
+ * all) shares one request through react-query rather than making a second.
38
+ */
39
+ export function FilmRentalList({ basePath = "/i/films", navigate, className, style }: FilmRentalListProps) {
40
+ const t = useForgeT();
41
+ const tokens = useThemeTokens();
42
+ const { user, isInitialized } = usePublicAuth();
43
+ const { data, isLoading } = useMyFilmRentals(user?.id);
44
+ const go = navigate ?? ((href: string) => window.location.assign(href));
45
+
46
+ if (!isInitialized || isLoading) return <Loading />;
47
+
48
+ const rentals = data?.data ?? [];
49
+
50
+ const notice: CSSProperties = {
51
+ padding: "28px 20px",
52
+ borderRadius: tokens.cornerRadius,
53
+ border: `1px solid ${tokens.border}`,
54
+ background: tokens.surface,
55
+ color: tokens.muted,
56
+ textAlign: "center",
57
+ fontSize: 14,
58
+ };
59
+
60
+ return (
61
+ <div
62
+ className={className}
63
+ style={{
64
+ display: "flex",
65
+ flexDirection: "column",
66
+ gap: 16,
67
+ color: tokens.text,
68
+ fontFamily: tokens.fontFamily,
69
+ ...style,
70
+ }}
71
+ >
72
+ {rentals.length === 0 && (
73
+ <div style={notice}>
74
+ <p style={{ marginBottom: 12 }}>{t("forge.film_library.empty")}</p>
75
+ <button
76
+ type="button"
77
+ onClick={() => go(basePath)}
78
+ style={{
79
+ padding: "10px 18px",
80
+ borderRadius: tokens.cornerRadius,
81
+ border: "none",
82
+ background: tokens.primary,
83
+ color: tokens.textPrimary,
84
+ fontSize: 14,
85
+ fontWeight: 700,
86
+ cursor: "pointer",
87
+ }}
88
+ >
89
+ {t("forge.film_library.browse")}
90
+ </button>
91
+ </div>
92
+ )}
93
+
94
+ {rentals.map((rental) => (
95
+ <RentalRow key={rental.id} rental={rental} t={t} basePath={basePath} go={go} />
96
+ ))}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The fan's own rentals as a PAGE: what is running, what has not been started,
103
+ * and what has run out.
104
+ *
105
+ * Expired rentals are here deliberately. The library is where a fan re-rents,
106
+ * and a rental that vanishes on expiry reads as "you were robbed" rather than
107
+ * "that ended" - so an expired row keeps its title, states plainly that the
108
+ * window closed, and offers the way back.
109
+ *
110
+ * A row states one fact the reader must act on, so it stays visible rather than
111
+ * going behind a `?`: how long is left. There is no "what was bought" line any
112
+ * more, because a rental is always the whole title and the title is the row.
113
+ */
114
+ export function FilmLibrary({ basePath, navigate, className, style }: FilmLibraryProps) {
115
+ const t = useForgeT();
116
+ const tokens = useThemeTokens();
117
+
118
+ return (
119
+ <div
120
+ className={className}
121
+ style={{
122
+ display: "flex",
123
+ flexDirection: "column",
124
+ gap: 16,
125
+ maxWidth: 900,
126
+ margin: "0 auto",
127
+ color: tokens.text,
128
+ fontFamily: tokens.fontFamily,
129
+ ...style,
130
+ }}
131
+ >
132
+ <h1
133
+ style={{
134
+ fontSize: 24,
135
+ fontWeight: 800,
136
+ fontFamily: tokens.headingFontFamily || tokens.fontFamily,
137
+ }}
138
+ >
139
+ {t("forge.film_library.heading")}
140
+ </h1>
141
+
142
+ <FilmRentalList basePath={basePath} navigate={navigate} />
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /** Where the fan got to in the episode they should resume, or null. */
148
+ function resumeTarget(rental: MyFilmRental): { episodeId: string; entry: FilmWatchProgressEntry } | null {
149
+ // The furthest-along INCOMPLETE episode: resuming a finished one is how a
150
+ // binge gets stuck on the episode it just watched.
151
+ const candidates = rental.progress.filter((entry) => !entry.completedAt && entry.positionSec > 30);
152
+ const best = candidates.reduce<FilmWatchProgressEntry | null>(
153
+ (acc, entry) => (!acc || entry.positionSec > acc.positionSec ? entry : acc),
154
+ null,
155
+ );
156
+ return best ? { episodeId: best.episodeId, entry: best } : null;
157
+ }
158
+
159
+ function RentalRow({
160
+ rental,
161
+ t,
162
+ basePath,
163
+ go,
164
+ }: {
165
+ rental: MyFilmRental;
166
+ t: ForgeT;
167
+ basePath: string;
168
+ go: (href: string) => void;
169
+ }) {
170
+ const tokens = useThemeTokens();
171
+ const remaining = splitRemainingMs(rental.msRemaining);
172
+ const resume = resumeTarget(rental);
173
+
174
+ const windowLabel = !rental.isLive
175
+ ? rental.refundedAt
176
+ ? t("forge.film_library.window_refunded")
177
+ : t("forge.film_library.window_expired")
178
+ : rental.msRemaining == null
179
+ ? t("forge.film_library.window_not_started", { window: rentalWindowLabel(t, rental.rentalDurationHours) })
180
+ : remaining && remaining.days > 0
181
+ ? t("forge.film_library.window_days_left", { days: remaining.days, hours: remaining.hours })
182
+ : remaining && remaining.hours > 0
183
+ ? t("forge.film_library.window_hours_left", { hours: remaining.hours, minutes: remaining.minutes })
184
+ : t("forge.film_library.window_minutes_left", { minutes: remaining?.minutes ?? 0 });
185
+
186
+ // Only continue-watching can name an episode from here: `my-rentals` carries
187
+ // the title, not its episode list. With nothing to resume the row sends the
188
+ // fan to the title page, which knows where to start.
189
+ const watchTarget = resume?.episodeId ?? null;
190
+
191
+ return (
192
+ <div
193
+ style={{
194
+ display: "flex",
195
+ gap: 14,
196
+ alignItems: "center",
197
+ flexWrap: "wrap",
198
+ padding: 14,
199
+ borderRadius: tokens.cornerRadius,
200
+ border: `1px solid ${tokens.border}`,
201
+ background: tokens.surface,
202
+ opacity: rental.isLive ? 1 : 0.85,
203
+ }}
204
+ >
205
+ {rental.film.cover?.url && (
206
+ <img
207
+ src={rental.film.cover.url}
208
+ alt=""
209
+ style={{
210
+ width: 64,
211
+ aspectRatio: "2/3",
212
+ objectFit: "cover",
213
+ borderRadius: tokens.cornerRadius,
214
+ // The one accepted literal: a letterbox behind artwork.
215
+ background: "#000",
216
+ }}
217
+ />
218
+ )}
219
+
220
+ <div style={{ flex: "1 1 220px", minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
221
+ <span style={{ fontSize: 16, fontWeight: 700 }}>{rental.film.title}</span>
222
+ {/* State the reader must act on: visible, never behind a `?`. */}
223
+ <span style={{ fontSize: 13, fontWeight: rental.isLive ? 600 : 400 }}>{windowLabel}</span>
224
+ </div>
225
+
226
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
227
+ {rental.isLive && watchTarget && (
228
+ <button
229
+ type="button"
230
+ onClick={() => go(`${basePath}/${rental.film.slug}/watch/${watchTarget}`)}
231
+ style={{
232
+ padding: "9px 16px",
233
+ borderRadius: tokens.cornerRadius,
234
+ border: "none",
235
+ background: tokens.primary,
236
+ color: tokens.textPrimary,
237
+ fontSize: 13,
238
+ fontWeight: 700,
239
+ cursor: "pointer",
240
+ }}
241
+ >
242
+ {resume ? t("forge.film_library.resume") : t("forge.film_library.watch")}
243
+ </button>
244
+ )}
245
+
246
+ {rental.isLive && !watchTarget && (
247
+ <button
248
+ type="button"
249
+ onClick={() => go(`${basePath}/${rental.film.slug}`)}
250
+ style={{
251
+ padding: "9px 16px",
252
+ borderRadius: tokens.cornerRadius,
253
+ border: "none",
254
+ background: tokens.primary,
255
+ color: tokens.textPrimary,
256
+ fontSize: 13,
257
+ fontWeight: 700,
258
+ cursor: "pointer",
259
+ }}
260
+ >
261
+ {t("forge.film_library.watch")}
262
+ </button>
263
+ )}
264
+
265
+ {!rental.isLive && (
266
+ <button
267
+ type="button"
268
+ // An archived title no longer sells, so the honest move is still to
269
+ // send them to the page and let it say so, rather than to draw a
270
+ // button here that promises a purchase we cannot complete.
271
+ onClick={() => go(`${basePath}/${rental.film.slug}`)}
272
+ style={{
273
+ padding: "9px 16px",
274
+ borderRadius: tokens.cornerRadius,
275
+ border: `1px solid ${tokens.primary}`,
276
+ background: "transparent",
277
+ color: tokens.primary,
278
+ fontSize: 13,
279
+ fontWeight: 600,
280
+ cursor: "pointer",
281
+ }}
282
+ >
283
+ {t("forge.film_library.rent_again")}
284
+ </button>
285
+ )}
286
+ </div>
287
+ </div>
288
+ );
289
+ }
290
+
291
+ export default FilmLibrary;