@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.
- package/package.json +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- 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 +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- 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 +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- 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;
|