@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.
Files changed (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  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 +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,254 @@
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 FilmLibraryProps {
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
+ /**
24
+ * The fan's own rentals: what is running, what has not been started, and what
25
+ * has run out.
26
+ *
27
+ * Expired rentals are here deliberately. The library is where a fan re-rents,
28
+ * and a rental that vanishes on expiry reads as "you were robbed" rather than
29
+ * "that ended" - so an expired row keeps its title, states plainly that the
30
+ * window closed, and offers the way back.
31
+ *
32
+ * A row states one fact the reader must act on, so it stays visible rather than
33
+ * going behind a `?`: how long is left. There is no "what was bought" line any
34
+ * more, because a rental is always the whole title and the title is the row.
35
+ */
36
+ export function FilmLibrary({ basePath = "/i/films", navigate, className, style }: FilmLibraryProps) {
37
+ const t = useForgeT();
38
+ const tokens = useThemeTokens();
39
+ const { user, isInitialized } = usePublicAuth();
40
+ const { data, isLoading } = useMyFilmRentals(user?.id);
41
+ const go = navigate ?? ((href: string) => window.location.assign(href));
42
+
43
+ if (!isInitialized || isLoading) return <Loading fullPage />;
44
+
45
+ const rentals = data?.data ?? [];
46
+
47
+ const notice: CSSProperties = {
48
+ padding: "28px 20px",
49
+ borderRadius: tokens.cornerRadius,
50
+ border: `1px solid ${tokens.border}`,
51
+ background: tokens.surface,
52
+ color: tokens.muted,
53
+ textAlign: "center",
54
+ fontSize: 14,
55
+ };
56
+
57
+ return (
58
+ <div
59
+ className={className}
60
+ style={{
61
+ display: "flex",
62
+ flexDirection: "column",
63
+ gap: 16,
64
+ maxWidth: 900,
65
+ margin: "0 auto",
66
+ color: tokens.text,
67
+ fontFamily: tokens.fontFamily,
68
+ ...style,
69
+ }}
70
+ >
71
+ <h1
72
+ style={{
73
+ fontSize: 24,
74
+ fontWeight: 800,
75
+ fontFamily: tokens.headingFontFamily || tokens.fontFamily,
76
+ }}
77
+ >
78
+ {t("forge.film_library.heading")}
79
+ </h1>
80
+
81
+ {rentals.length === 0 && (
82
+ <div style={notice}>
83
+ <p style={{ marginBottom: 12 }}>{t("forge.film_library.empty")}</p>
84
+ <button
85
+ type="button"
86
+ onClick={() => go(basePath)}
87
+ style={{
88
+ padding: "10px 18px",
89
+ borderRadius: tokens.cornerRadius,
90
+ border: "none",
91
+ background: tokens.primary,
92
+ color: tokens.textPrimary,
93
+ fontSize: 14,
94
+ fontWeight: 700,
95
+ cursor: "pointer",
96
+ }}
97
+ >
98
+ {t("forge.film_library.browse")}
99
+ </button>
100
+ </div>
101
+ )}
102
+
103
+ {rentals.map((rental) => (
104
+ <RentalRow key={rental.id} rental={rental} t={t} basePath={basePath} go={go} />
105
+ ))}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ /** Where the fan got to in the episode they should resume, or null. */
111
+ function resumeTarget(rental: MyFilmRental): { episodeId: string; entry: FilmWatchProgressEntry } | null {
112
+ // The furthest-along INCOMPLETE episode: resuming a finished one is how a
113
+ // binge gets stuck on the episode it just watched.
114
+ const candidates = rental.progress.filter((entry) => !entry.completedAt && entry.positionSec > 30);
115
+ const best = candidates.reduce<FilmWatchProgressEntry | null>(
116
+ (acc, entry) => (!acc || entry.positionSec > acc.positionSec ? entry : acc),
117
+ null,
118
+ );
119
+ return best ? { episodeId: best.episodeId, entry: best } : null;
120
+ }
121
+
122
+ function RentalRow({
123
+ rental,
124
+ t,
125
+ basePath,
126
+ go,
127
+ }: {
128
+ rental: MyFilmRental;
129
+ t: ForgeT;
130
+ basePath: string;
131
+ go: (href: string) => void;
132
+ }) {
133
+ const tokens = useThemeTokens();
134
+ const remaining = splitRemainingMs(rental.msRemaining);
135
+ const resume = resumeTarget(rental);
136
+
137
+ const windowLabel = !rental.isLive
138
+ ? rental.refundedAt
139
+ ? t("forge.film_library.window_refunded")
140
+ : t("forge.film_library.window_expired")
141
+ : rental.msRemaining == null
142
+ ? t("forge.film_library.window_not_started", { window: rentalWindowLabel(t, rental.rentalDurationHours) })
143
+ : remaining && remaining.days > 0
144
+ ? t("forge.film_library.window_days_left", { days: remaining.days, hours: remaining.hours })
145
+ : remaining && remaining.hours > 0
146
+ ? t("forge.film_library.window_hours_left", { hours: remaining.hours, minutes: remaining.minutes })
147
+ : t("forge.film_library.window_minutes_left", { minutes: remaining?.minutes ?? 0 });
148
+
149
+ // Only continue-watching can name an episode from here: `my-rentals` carries
150
+ // the title, not its episode list. With nothing to resume the row sends the
151
+ // fan to the title page, which knows where to start.
152
+ const watchTarget = resume?.episodeId ?? null;
153
+
154
+ return (
155
+ <div
156
+ style={{
157
+ display: "flex",
158
+ gap: 14,
159
+ alignItems: "center",
160
+ flexWrap: "wrap",
161
+ padding: 14,
162
+ borderRadius: tokens.cornerRadius,
163
+ border: `1px solid ${tokens.border}`,
164
+ background: tokens.surface,
165
+ opacity: rental.isLive ? 1 : 0.85,
166
+ }}
167
+ >
168
+ {rental.film.cover?.url && (
169
+ <img
170
+ src={rental.film.cover.url}
171
+ alt=""
172
+ style={{
173
+ width: 64,
174
+ aspectRatio: "2/3",
175
+ objectFit: "cover",
176
+ borderRadius: tokens.cornerRadius,
177
+ // The one accepted literal: a letterbox behind artwork.
178
+ background: "#000",
179
+ }}
180
+ />
181
+ )}
182
+
183
+ <div style={{ flex: "1 1 220px", minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
184
+ <span style={{ fontSize: 16, fontWeight: 700 }}>{rental.film.title}</span>
185
+ {/* State the reader must act on: visible, never behind a `?`. */}
186
+ <span style={{ fontSize: 13, fontWeight: rental.isLive ? 600 : 400 }}>{windowLabel}</span>
187
+ </div>
188
+
189
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
190
+ {rental.isLive && watchTarget && (
191
+ <button
192
+ type="button"
193
+ onClick={() => go(`${basePath}/${rental.film.slug}/watch/${watchTarget}`)}
194
+ style={{
195
+ padding: "9px 16px",
196
+ borderRadius: tokens.cornerRadius,
197
+ border: "none",
198
+ background: tokens.primary,
199
+ color: tokens.textPrimary,
200
+ fontSize: 13,
201
+ fontWeight: 700,
202
+ cursor: "pointer",
203
+ }}
204
+ >
205
+ {resume ? t("forge.film_library.resume") : t("forge.film_library.watch")}
206
+ </button>
207
+ )}
208
+
209
+ {rental.isLive && !watchTarget && (
210
+ <button
211
+ type="button"
212
+ onClick={() => go(`${basePath}/${rental.film.slug}`)}
213
+ style={{
214
+ padding: "9px 16px",
215
+ borderRadius: tokens.cornerRadius,
216
+ border: "none",
217
+ background: tokens.primary,
218
+ color: tokens.textPrimary,
219
+ fontSize: 13,
220
+ fontWeight: 700,
221
+ cursor: "pointer",
222
+ }}
223
+ >
224
+ {t("forge.film_library.watch")}
225
+ </button>
226
+ )}
227
+
228
+ {!rental.isLive && (
229
+ <button
230
+ type="button"
231
+ // An archived title no longer sells, so the honest move is still to
232
+ // send them to the page and let it say so, rather than to draw a
233
+ // button here that promises a purchase we cannot complete.
234
+ onClick={() => go(`${basePath}/${rental.film.slug}`)}
235
+ style={{
236
+ padding: "9px 16px",
237
+ borderRadius: tokens.cornerRadius,
238
+ border: `1px solid ${tokens.primary}`,
239
+ background: "transparent",
240
+ color: tokens.primary,
241
+ fontSize: 13,
242
+ fontWeight: 600,
243
+ cursor: "pointer",
244
+ }}
245
+ >
246
+ {t("forge.film_library.rent_again")}
247
+ </button>
248
+ )}
249
+ </div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ export default FilmLibrary;