@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,561 @@
|
|
|
1
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import type { PaginatedData } from "../../types/models";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Film and series rentals: the catalogue, one title, the buyer's library, and
|
|
7
|
+
* the two write legs of a rental purchase.
|
|
8
|
+
*
|
|
9
|
+
* A "film" here is a TITLE, not a video. Everything playable is an episode, and
|
|
10
|
+
* a single film is a title carrying exactly one hidden episode. That is why the
|
|
11
|
+
* detail payload always has an `episodes` array and why `primaryEpisodeId` is
|
|
12
|
+
* non-null only for `kind === "film"`: a fan renting a film never sees the word
|
|
13
|
+
* episode, but the player still plays one.
|
|
14
|
+
*
|
|
15
|
+
* Money is integer CENTS on every field that ends in `Cents`. Divide by 100
|
|
16
|
+
* exactly once, at the moment you format it, and never store the divided value.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type FilmKind = "film" | "series";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A rental is always for a whole TITLE. There is one grain and no other.
|
|
23
|
+
*
|
|
24
|
+
* It covers every episode of the title that is available while the window runs,
|
|
25
|
+
* including episodes published mid-window, and it owns ONE clock: the clock
|
|
26
|
+
* starts at the first play of any covered episode and does not restart when the
|
|
27
|
+
* fan moves to the next one.
|
|
28
|
+
*
|
|
29
|
+
* Per-episode rental was written into the spec speculatively, nobody asked for
|
|
30
|
+
* it, and it produced a double-charge defect on the first pass. It was cut, not
|
|
31
|
+
* deferred, so there is no scope field to branch on anywhere below.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The stored status. Note what is NOT here: `expired`.
|
|
36
|
+
*
|
|
37
|
+
* Expiry is computed from `expiresAt` at read time and surfaces as `isLive`,
|
|
38
|
+
* so nothing ever sweeps rows to expire them and a rental that ran out still
|
|
39
|
+
* reads as `active`. Ask `isLive`, never `status === "active"`, when the
|
|
40
|
+
* question is "can this person watch right now".
|
|
41
|
+
*/
|
|
42
|
+
export type FilmRentalStatus = "pending_payment" | "active" | "refunded" | "revoked";
|
|
43
|
+
|
|
44
|
+
export interface FilmMedia {
|
|
45
|
+
id: string;
|
|
46
|
+
url: string;
|
|
47
|
+
type: string;
|
|
48
|
+
filename: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One window a title is sold in: a price and how long it runs.
|
|
53
|
+
*
|
|
54
|
+
* A title carries a LIST of these, so the same film can be 8 hours cheaply and
|
|
55
|
+
* 48 hours for more. The list is live options only - an archived window is
|
|
56
|
+
* absent from the payload rather than present and flagged, so nothing here can
|
|
57
|
+
* draw an offer the checkout endpoint would refuse.
|
|
58
|
+
*
|
|
59
|
+
* `id` is the whole of what checkout sends. Price and duration are resolved
|
|
60
|
+
* server-side FROM THIS ROW and are never read off the request body, which is
|
|
61
|
+
* the client-set-price defect the 2026-06-10 audit found. The numbers below
|
|
62
|
+
* exist so the fan can be told what they are buying before they buy it.
|
|
63
|
+
*/
|
|
64
|
+
export interface PublicFilmRentalOption {
|
|
65
|
+
id: string;
|
|
66
|
+
/** Creator's own name for the window ("Weekend pass"). Null means name it from the hours. */
|
|
67
|
+
label: string | null;
|
|
68
|
+
priceCents: number;
|
|
69
|
+
durationHours: number;
|
|
70
|
+
/** Creator's ordering. The API already sorts cheapest-first; this breaks ties. */
|
|
71
|
+
position: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The fields every public film payload carries, list and detail alike. */
|
|
75
|
+
export interface PublicFilmBase {
|
|
76
|
+
id: string;
|
|
77
|
+
kind: FilmKind;
|
|
78
|
+
title: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
description: string | null;
|
|
81
|
+
currency: string;
|
|
82
|
+
/**
|
|
83
|
+
* The windows this title is on sale in, cheapest first. Live options only.
|
|
84
|
+
*
|
|
85
|
+
* An empty array means the title is not on sale. There is no title-level price
|
|
86
|
+
* any more: `films.rental_price_cents` and `films.rental_duration_hours` were
|
|
87
|
+
* dropped, and every priced title was migrated into exactly one option.
|
|
88
|
+
*/
|
|
89
|
+
rentalOptions: PublicFilmRentalOption[];
|
|
90
|
+
/** The cheapest option's price, for a catalogue card. Null when nothing is on sale. */
|
|
91
|
+
fromPriceCents: number | null;
|
|
92
|
+
publishedAt: string | null;
|
|
93
|
+
cover: FilmMedia | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PublicFilmSummary extends PublicFilmBase {
|
|
97
|
+
/** Episodes a fan could actually play today (released, encoded, not archived). */
|
|
98
|
+
availableEpisodeCount: number;
|
|
99
|
+
hasFreePreview: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One playable episode as the storefront sees it.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately narrow: the API builds this from an explicit public projection,
|
|
106
|
+
* so `hlsPrefix`, `renditions`, `sourceMediaId` and `transcodeError` are absent
|
|
107
|
+
* at every depth rather than present and ignored. Do not widen this type to
|
|
108
|
+
* "whatever the admin payload has" - the paywall is asserted on the bytes of
|
|
109
|
+
* this response.
|
|
110
|
+
*/
|
|
111
|
+
export interface PublicFilmEpisode {
|
|
112
|
+
id: string;
|
|
113
|
+
filmId: string;
|
|
114
|
+
seasonNumber: number;
|
|
115
|
+
episodeNumber: number;
|
|
116
|
+
title: string | null;
|
|
117
|
+
description: string | null;
|
|
118
|
+
durationSec: number | null;
|
|
119
|
+
/** Drip release. In the future means listed-but-not-yet-playable. */
|
|
120
|
+
availableAt: string | null;
|
|
121
|
+
/** Plays with no rental at all, but still needs a session and is still watermarked. */
|
|
122
|
+
isFreePreview: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A rental as its owner may see it.
|
|
127
|
+
*
|
|
128
|
+
* `paymentId`, `paymentProvider`, `accountId`, `profileId` and `email` are never
|
|
129
|
+
* in this payload, by construction on the server.
|
|
130
|
+
*/
|
|
131
|
+
export interface PublicFilmRental {
|
|
132
|
+
id: string;
|
|
133
|
+
filmId: string;
|
|
134
|
+
status: FilmRentalStatus;
|
|
135
|
+
amountCents: number;
|
|
136
|
+
currency: string;
|
|
137
|
+
/** Snapshotted at purchase, so a later admin edit cannot shorten what was sold. */
|
|
138
|
+
rentalDurationHours: number;
|
|
139
|
+
/**
|
|
140
|
+
* Which window was bought, for reporting. Null once that option is deleted.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ Not the terms. `amountCents` and `rentalDurationHours` above are the
|
|
143
|
+
* snapshot of what was actually sold, and they stand even after the option is
|
|
144
|
+
* edited or archived. Never read a price or a window back through this
|
|
145
|
+
* pointer.
|
|
146
|
+
*/
|
|
147
|
+
rentalOptionId: string | null;
|
|
148
|
+
paidAt: string | null;
|
|
149
|
+
/** Shelf life: the rental must be STARTED by this instant or it lapses unwatched. */
|
|
150
|
+
startBy: string | null;
|
|
151
|
+
firstPlayedAt: string | null;
|
|
152
|
+
expiresAt: string | null;
|
|
153
|
+
refundedAt: string | null;
|
|
154
|
+
revokedAt: string | null;
|
|
155
|
+
/** The only honest answer to "can this person watch right now". */
|
|
156
|
+
isLive: boolean;
|
|
157
|
+
/** Null until first play - the clock starts then, not at purchase. */
|
|
158
|
+
msRemaining: number | null;
|
|
159
|
+
windowMs: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface PublicFilmDetail extends PublicFilmBase {
|
|
163
|
+
trailer: FilmMedia | null;
|
|
164
|
+
/** AVAILABLE episodes only. An unreleased one is absent, not flagged. */
|
|
165
|
+
episodes: PublicFilmEpisode[];
|
|
166
|
+
/** The one episode behind a single film. Null for a series. */
|
|
167
|
+
primaryEpisodeId: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* May the offer be drawn at all?
|
|
170
|
+
*
|
|
171
|
+
* Decided by the server on the same rule that guards `POST .../rentals`: at
|
|
172
|
+
* least one live option AND at least one episode playable inside the LONGEST
|
|
173
|
+
* window a buyer could get. A published title keeps its options after every
|
|
174
|
+
* one of its episodes has failed transcode or been archived, so gating the CTA
|
|
175
|
+
* on the option list alone paints a Rent button that takes money for a stream
|
|
176
|
+
* every playback endpoint will 404.
|
|
177
|
+
*/
|
|
178
|
+
canRentTitle: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* The live rental this viewer holds on this title, or null.
|
|
181
|
+
*
|
|
182
|
+
* Singular, and that is now a fact about the data rather than a simplification
|
|
183
|
+
* of it: one grain means at most one live rental per (account, title), so this
|
|
184
|
+
* one row answers both the countdown and every per-episode affordance.
|
|
185
|
+
* Present only when the request carried a fan session.
|
|
186
|
+
*/
|
|
187
|
+
viewerRental: PublicFilmRental | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface FilmCatalogPage {
|
|
191
|
+
data: PublicFilmSummary[];
|
|
192
|
+
total: number;
|
|
193
|
+
page: number;
|
|
194
|
+
limit: number;
|
|
195
|
+
/**
|
|
196
|
+
* Is the film-rentals feature switch on for this tenant?
|
|
197
|
+
*
|
|
198
|
+
* The catalogue answers 200 with an empty list rather than 404 when it is off,
|
|
199
|
+
* because `/i/films` ships to every site that takes a starter update and
|
|
200
|
+
* cannot itself be gated. Draw a "nothing here" state, not an error.
|
|
201
|
+
*/
|
|
202
|
+
enabled: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Where the fan got to in one episode, under one rental. */
|
|
206
|
+
export interface FilmWatchProgressEntry {
|
|
207
|
+
episodeId: string;
|
|
208
|
+
positionSec: number;
|
|
209
|
+
completedAt: string | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface MyFilmRental extends PublicFilmRental {
|
|
213
|
+
film: {
|
|
214
|
+
id: string;
|
|
215
|
+
title: string;
|
|
216
|
+
slug: string;
|
|
217
|
+
kind: FilmKind;
|
|
218
|
+
status: string;
|
|
219
|
+
archivedAt: string | null;
|
|
220
|
+
cover: FilmMedia | null;
|
|
221
|
+
};
|
|
222
|
+
progress: FilmWatchProgressEntry[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface GetFilmsParams {
|
|
226
|
+
page?: number;
|
|
227
|
+
/** Server caps this at 50. */
|
|
228
|
+
limit?: number;
|
|
229
|
+
kind?: FilmKind;
|
|
230
|
+
query?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The published catalogue. Public - no session needed. */
|
|
234
|
+
export function useFilms(params?: GetFilmsParams, enabled = true) {
|
|
235
|
+
const { client, profileId } = useForge();
|
|
236
|
+
|
|
237
|
+
return useQuery<FilmCatalogPage>({
|
|
238
|
+
queryKey: ["films", profileId, params],
|
|
239
|
+
queryFn: async () => {
|
|
240
|
+
const res = await client.get("/public/films", {
|
|
241
|
+
params: {
|
|
242
|
+
profileId,
|
|
243
|
+
page: params?.page || 1,
|
|
244
|
+
limit: params?.limit || 20,
|
|
245
|
+
kind: params?.kind,
|
|
246
|
+
query: params?.query || undefined,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return res.data;
|
|
250
|
+
},
|
|
251
|
+
enabled: !!profileId && !!client && enabled,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One title by slug, with its available episodes.
|
|
257
|
+
*
|
|
258
|
+
* `viewerRental` only comes back when the caller is signed in, so this query is
|
|
259
|
+
* keyed on the account: a fan who signs in must not keep reading the anonymous
|
|
260
|
+
* cache entry and be told they own nothing.
|
|
261
|
+
*
|
|
262
|
+
* 404s for a draft, an archived title, another tenant's slug, and while the
|
|
263
|
+
* feature switch is off. All four are the same answer on purpose - a storefront
|
|
264
|
+
* must not be able to tell "not yours" from "does not exist".
|
|
265
|
+
*/
|
|
266
|
+
export function useFilm(
|
|
267
|
+
slug?: string,
|
|
268
|
+
options?: { initialFilm?: PublicFilmDetail; accountId?: string; enabled?: boolean },
|
|
269
|
+
) {
|
|
270
|
+
const { client, profileId } = useForge();
|
|
271
|
+
|
|
272
|
+
return useQuery<PublicFilmDetail>({
|
|
273
|
+
queryKey: ["film", profileId, slug, options?.accountId ?? null],
|
|
274
|
+
queryFn: async () => {
|
|
275
|
+
const res = await client.get(`/public/films/${slug}`, { params: { profileId } });
|
|
276
|
+
return res.data;
|
|
277
|
+
},
|
|
278
|
+
enabled: !!profileId && !!client && !!slug && options?.enabled !== false,
|
|
279
|
+
initialData: options?.initialFilm,
|
|
280
|
+
retry: false,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The signed-in fan's rentals: active, not-yet-started AND expired.
|
|
286
|
+
*
|
|
287
|
+
* Expired ones are included deliberately - the library is where a fan re-rents,
|
|
288
|
+
* and a rental that vanishes on expiry reads as "you were robbed" rather than
|
|
289
|
+
* "that ended". `pending_payment` rows are excluded server-side, because an
|
|
290
|
+
* abandoned checkout is not a purchase.
|
|
291
|
+
*
|
|
292
|
+
* The buyer is resolved from the SESSION server-side; `accountId` here only
|
|
293
|
+
* gates the query on being signed in and keys the cache.
|
|
294
|
+
*/
|
|
295
|
+
export function useMyFilmRentals(accountId?: string, page = 1, limit = 20) {
|
|
296
|
+
const { client, profileId } = useForge();
|
|
297
|
+
|
|
298
|
+
return useQuery<PaginatedData<MyFilmRental>>({
|
|
299
|
+
queryKey: ["my-film-rentals", accountId, profileId, page, limit],
|
|
300
|
+
queryFn: async () => {
|
|
301
|
+
const res = await client.get("/public/films/my-rentals", { params: { profileId, page, limit } });
|
|
302
|
+
return res.data;
|
|
303
|
+
},
|
|
304
|
+
enabled: !!accountId && !!profileId && !!client,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Create the `pending_payment` rental for the whole title, in one chosen window.
|
|
310
|
+
*
|
|
311
|
+
* The body names WHICH window (`rentalOptionId`) and nothing else. It carries no
|
|
312
|
+
* price, no duration and no episode: both numbers are resolved server-side from
|
|
313
|
+
* the option ROW, and the body is `.strict()`, so a client that sends a price
|
|
314
|
+
* gets a 400 rather than a quietly ignored field. That is the client-set-price
|
|
315
|
+
* defect from the 2026-06-10 audit, and it costs one line to not repeat.
|
|
316
|
+
*
|
|
317
|
+
* There is no default option. Sending no `rentalOptionId` is a 400, because the
|
|
318
|
+
* alternative - silently picking one - charges somebody for a window they never
|
|
319
|
+
* chose.
|
|
320
|
+
*
|
|
321
|
+
* Idempotent in the way that matters: a second call for the same
|
|
322
|
+
* (account, film) returns the EXISTING pending rental rather than minting a
|
|
323
|
+
* second row, so a double-tapped Rent button charges once. Picking a DIFFERENT
|
|
324
|
+
* window before paying re-points that same row instead of opening a second
|
|
325
|
+
* checkout.
|
|
326
|
+
*
|
|
327
|
+
* A price of 0 comes back already `active`, with `paidAt` and `startBy` stamped
|
|
328
|
+
* and the receipt sent. Check `status` before starting a payment.
|
|
329
|
+
*/
|
|
330
|
+
export function useCreateFilmRental(filmId?: string) {
|
|
331
|
+
const { client, profileId } = useForge();
|
|
332
|
+
const queryClient = useQueryClient();
|
|
333
|
+
|
|
334
|
+
return useMutation<PublicFilmRental, unknown, { rentalOptionId: string }>({
|
|
335
|
+
mutationFn: async ({ rentalOptionId }) => {
|
|
336
|
+
const res = await client.post(`/public/films/${filmId}/rentals`, { profileId, rentalOptionId });
|
|
337
|
+
return res.data;
|
|
338
|
+
},
|
|
339
|
+
onSuccess: () => {
|
|
340
|
+
void queryClient.invalidateQueries({ queryKey: ["my-film-rentals"] });
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Reconcile a rental with the payment provider after the return leg.
|
|
347
|
+
*
|
|
348
|
+
* Idempotent, and shared with the webhook path: whichever arrives first flips
|
|
349
|
+
* `pending_payment` to `active`, and the loser is handed the same row back
|
|
350
|
+
* untouched. Modeled as a load-time query because a return page's job is to run
|
|
351
|
+
* this exactly once on mount.
|
|
352
|
+
*/
|
|
353
|
+
export function useFilmRentalFinalize(filmId?: string, rentalId?: string) {
|
|
354
|
+
const { client, profileId } = useForge();
|
|
355
|
+
const queryClient = useQueryClient();
|
|
356
|
+
|
|
357
|
+
return useQuery<PublicFilmRental>({
|
|
358
|
+
queryKey: ["film-rental-finalize", profileId, filmId, rentalId],
|
|
359
|
+
queryFn: async () => {
|
|
360
|
+
const res = await client.post(`/public/films/${filmId}/rentals/${rentalId}/finalize`, { profileId });
|
|
361
|
+
void queryClient.invalidateQueries({ queryKey: ["my-film-rentals"] });
|
|
362
|
+
void queryClient.invalidateQueries({ queryKey: ["film", profileId] });
|
|
363
|
+
return res.data;
|
|
364
|
+
},
|
|
365
|
+
enabled: !!profileId && !!client && !!filmId && !!rentalId,
|
|
366
|
+
retry: false,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Whole hours as a short label input, e.g. 48 -> `{ days: 2, hours: 0 }`. */
|
|
371
|
+
export function splitRentalHours(hours: number): { days: number; hours: number } {
|
|
372
|
+
const safe = Number.isFinite(hours) && hours > 0 ? Math.floor(hours) : 0;
|
|
373
|
+
return { days: Math.floor(safe / 24), hours: safe % 24 };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A runtime in seconds as hours and whole minutes. Total over null and nonsense. */
|
|
377
|
+
export function splitDurationSec(seconds: number | null | undefined): { hours: number; minutes: number } | null {
|
|
378
|
+
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null;
|
|
379
|
+
const whole = Math.floor(seconds);
|
|
380
|
+
return { hours: Math.floor(whole / 3600), minutes: Math.round((whole % 3600) / 60) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A countdown in milliseconds as days, hours and minutes.
|
|
385
|
+
*
|
|
386
|
+
* Rounds DOWN at every level, deliberately. A window with 59 minutes left that
|
|
387
|
+
* rounds up to "1 hour" is a promise the clock will not keep, and the fan finds
|
|
388
|
+
* out at the worst possible moment.
|
|
389
|
+
*/
|
|
390
|
+
export function splitRemainingMs(ms: number | null | undefined): {
|
|
391
|
+
days: number;
|
|
392
|
+
hours: number;
|
|
393
|
+
minutes: number;
|
|
394
|
+
totalMinutes: number;
|
|
395
|
+
} | null {
|
|
396
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
397
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
398
|
+
return {
|
|
399
|
+
days: Math.floor(totalMinutes / 1440),
|
|
400
|
+
hours: Math.floor((totalMinutes % 1440) / 60),
|
|
401
|
+
minutes: totalMinutes % 60,
|
|
402
|
+
totalMinutes,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The windows this title is on sale in, IN THE CREATOR'S OWN ORDER.
|
|
408
|
+
*
|
|
409
|
+
* ⚠️ `position` is primary, and it has to be. The admin windows editor writes an
|
|
410
|
+
* explicit 1..N to every live row when a creator uses the up and down arrows,
|
|
411
|
+
* and the screen tells them in so many words: "Fans see these in this order, top
|
|
412
|
+
* first." The API honours it (`position asc, price_cents asc, id asc`), so a
|
|
413
|
+
* price-first re-sort here made that sentence false and made every reorder a
|
|
414
|
+
* creator ever performed invisible, since two windows on the same title almost
|
|
415
|
+
* never cost the same.
|
|
416
|
+
*
|
|
417
|
+
* Price then id break a tie, so a payload whose positions all match still lands
|
|
418
|
+
* somewhere stable rather than shuffling between renders. Sorting at all (rather
|
|
419
|
+
* than trusting the API) is for the hand-built payloads custom surfaces and
|
|
420
|
+
* fixtures pass in.
|
|
421
|
+
*
|
|
422
|
+
* "The cheapest window" is a separate question with its own answer below, and it
|
|
423
|
+
* is deliberately no longer "the first row".
|
|
424
|
+
*
|
|
425
|
+
* Total over a payload with no options at all, which is what "not on sale" looks
|
|
426
|
+
* like: an empty array, never null, so a caller can map it without a guard.
|
|
427
|
+
*/
|
|
428
|
+
export function filmRentalOptions(film: Pick<PublicFilmBase, "rentalOptions"> | undefined | null): PublicFilmRentalOption[] {
|
|
429
|
+
const options = film?.rentalOptions ?? [];
|
|
430
|
+
return [...options].sort((a, b) => a.position - b.position || a.priceCents - b.priceCents || a.id.localeCompare(b.id));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The window a fan gets if they never open the picker.
|
|
435
|
+
*
|
|
436
|
+
* The cheapest one, deliberately. Pre-selecting anything dearer would make the
|
|
437
|
+
* default choice the one that costs more, which is a decision the fan did not
|
|
438
|
+
* make. Null when the title is not on sale.
|
|
439
|
+
*
|
|
440
|
+
* ⚠️ Computed as an explicit minimum over the price, NOT as the first row of the
|
|
441
|
+
* list above: that list is ordered by the creator's hand, so index 0 is whatever
|
|
442
|
+
* window they chose to show first. Ties fall to the earlier row, which is the
|
|
443
|
+
* creator's ordering deciding between two identically priced windows.
|
|
444
|
+
*/
|
|
445
|
+
export function cheapestFilmRentalOption(
|
|
446
|
+
film: Pick<PublicFilmBase, "rentalOptions"> | undefined | null,
|
|
447
|
+
): PublicFilmRentalOption | null {
|
|
448
|
+
return filmRentalOptions(film).reduce<PublicFilmRentalOption | null>(
|
|
449
|
+
(cheapest, option) => (cheapest === null || option.priceCents < cheapest.priceCents ? option : cheapest),
|
|
450
|
+
null,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The number a catalogue card prints, in cents, or null when nothing is on sale.
|
|
456
|
+
*
|
|
457
|
+
* Prefers the server's own `fromPriceCents` and falls back to the cheapest
|
|
458
|
+
* option, so a summary payload and a hand-built one answer the same. A price of
|
|
459
|
+
* 0 is a real (free) window somebody chose to publish, so every check here is
|
|
460
|
+
* `!= null`, never truthiness.
|
|
461
|
+
*/
|
|
462
|
+
export function filmFromPriceCents(
|
|
463
|
+
film: (Partial<Pick<PublicFilmBase, "fromPriceCents">> & Pick<PublicFilmBase, "rentalOptions">) | undefined | null,
|
|
464
|
+
): number | null {
|
|
465
|
+
if (!film) return null;
|
|
466
|
+
if (film.fromPriceCents != null) return film.fromPriceCents;
|
|
467
|
+
return cheapestFilmRentalOption(film)?.priceCents ?? null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Is this episode released yet?
|
|
472
|
+
*
|
|
473
|
+
* The API already omits unreleased episodes from the public detail payload, so
|
|
474
|
+
* in practice this only matters for an episode read out of a cached page that
|
|
475
|
+
* crossed its own release moment. Total over a missing/garbage timestamp:
|
|
476
|
+
* anything unparseable counts as released, matching the server's
|
|
477
|
+
* `available_at IS NULL` arm.
|
|
478
|
+
*/
|
|
479
|
+
export function isFilmEpisodeReleased(
|
|
480
|
+
episode: Pick<PublicFilmEpisode, "availableAt">,
|
|
481
|
+
now: Date = new Date(),
|
|
482
|
+
): boolean {
|
|
483
|
+
if (!episode.availableAt) return true;
|
|
484
|
+
const at = new Date(episode.availableAt).getTime();
|
|
485
|
+
if (!Number.isFinite(at)) return true;
|
|
486
|
+
return at <= now.getTime();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Does this rental cover this episode?
|
|
491
|
+
*
|
|
492
|
+
* The client-side twin of the server's coverage clause, for drawing affordances
|
|
493
|
+
* only. It is NOT the entitlement: every playback endpoint re-derives coverage
|
|
494
|
+
* from the database on every request, so being wrong here draws a button that
|
|
495
|
+
* 404s rather than granting anything.
|
|
496
|
+
*
|
|
497
|
+
* One grain means one question: a rental covers every episode of its own title
|
|
498
|
+
* and nothing else. A free preview short-circuits to true with no rental at all,
|
|
499
|
+
* and it short-circuits COVERAGE only. Playability is a separate question, and
|
|
500
|
+
* an unreleased preview is still not playable.
|
|
501
|
+
*/
|
|
502
|
+
export function filmRentalCoversEpisode(
|
|
503
|
+
rental: Pick<PublicFilmRental, "filmId"> | null | undefined,
|
|
504
|
+
episode: Pick<PublicFilmEpisode, "id" | "filmId" | "isFreePreview">,
|
|
505
|
+
): boolean {
|
|
506
|
+
if (episode.isFreePreview) return true;
|
|
507
|
+
if (!rental) return false;
|
|
508
|
+
return rental.filmId === episode.filmId;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* May this viewer press play on this episode right now?
|
|
513
|
+
*
|
|
514
|
+
* Three things have to be true at once, and the reason they are answered
|
|
515
|
+
* together is that the two ways of getting them apart both strand somebody: a
|
|
516
|
+
* covered-but-expired rental drawing Watch sends a fan to a 404, and an
|
|
517
|
+
* unreleased episode drawing Watch does the same on a drip series.
|
|
518
|
+
*
|
|
519
|
+
* Free previews need no rental. Everything else needs a LIVE one on this title.
|
|
520
|
+
*/
|
|
521
|
+
export function canWatchFilmEpisode(
|
|
522
|
+
rental: Pick<PublicFilmRental, "filmId" | "isLive"> | null | undefined,
|
|
523
|
+
episode: Pick<PublicFilmEpisode, "id" | "filmId" | "isFreePreview" | "availableAt">,
|
|
524
|
+
now: Date = new Date(),
|
|
525
|
+
): boolean {
|
|
526
|
+
if (!isFilmEpisodeReleased(episode, now)) return false;
|
|
527
|
+
if (episode.isFreePreview) return true;
|
|
528
|
+
return !!rental?.isLive && rental.filmId === episode.filmId;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Episodes grouped by season, seasons and episodes both in ascending order. */
|
|
532
|
+
export function groupFilmEpisodesBySeason(
|
|
533
|
+
episodes: PublicFilmEpisode[],
|
|
534
|
+
): { seasonNumber: number; episodes: PublicFilmEpisode[] }[] {
|
|
535
|
+
const seasons = new Map<number, PublicFilmEpisode[]>();
|
|
536
|
+
for (const episode of episodes) {
|
|
537
|
+
const bucket = seasons.get(episode.seasonNumber);
|
|
538
|
+
if (bucket) bucket.push(episode);
|
|
539
|
+
else seasons.set(episode.seasonNumber, [episode]);
|
|
540
|
+
}
|
|
541
|
+
return [...seasons.entries()]
|
|
542
|
+
.sort((a, b) => a[0] - b[0])
|
|
543
|
+
.map(([seasonNumber, list]) => ({
|
|
544
|
+
seasonNumber,
|
|
545
|
+
episodes: [...list].sort((a, b) => a.episodeNumber - b.episodeNumber),
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The episode after this one, in season-then-number order, or null at the end.
|
|
551
|
+
*
|
|
552
|
+
* Used for the next-episode prompt. Only ever picks from the AVAILABLE episodes
|
|
553
|
+
* the API returned, so a series still releasing weekly correctly ends at the
|
|
554
|
+
* last one out rather than offering a link to nothing.
|
|
555
|
+
*/
|
|
556
|
+
export function nextFilmEpisode(episodes: PublicFilmEpisode[], currentEpisodeId: string): PublicFilmEpisode | null {
|
|
557
|
+
const ordered = groupFilmEpisodesBySeason(episodes).flatMap((season) => season.episodes);
|
|
558
|
+
const index = ordered.findIndex((episode) => episode.id === currentEpisodeId);
|
|
559
|
+
if (index < 0 || index === ordered.length - 1) return null;
|
|
560
|
+
return ordered[index + 1] ?? null;
|
|
561
|
+
}
|
|
@@ -93,6 +93,15 @@ export type MyBooking = {
|
|
|
93
93
|
/** What became of the money: `refunded`, `voided_free`, `manual_refund_required`, … */
|
|
94
94
|
cancellationOutcome: string | null;
|
|
95
95
|
createdAt: string;
|
|
96
|
+
/**
|
|
97
|
+
* The call's address, or null when this session has no call.
|
|
98
|
+
*
|
|
99
|
+
* Present only for a CONFIRMED session on video, because that is when the
|
|
100
|
+
* room record is written. It is the id in `/video-calls/:callId`, opaque on
|
|
101
|
+
* purpose: it is not derived from the booking id, so a link in an email
|
|
102
|
+
* reveals nothing about the booking behind it.
|
|
103
|
+
*/
|
|
104
|
+
videoCallId?: string | null;
|
|
96
105
|
/** The slot the booking currently points at — what a reschedule moves. */
|
|
97
106
|
coachingBookingSlotId: string;
|
|
98
107
|
/**
|
|
@@ -150,4 +150,19 @@ describe("Forge i18n: every key a component uses exists in every bundle", () =>
|
|
|
150
150
|
}
|
|
151
151
|
expect(mismatches, `Interpolation drift:\n${mismatches.join("\n")}`).toEqual([]);
|
|
152
152
|
});
|
|
153
|
+
|
|
154
|
+
it("no bundle value uses i18next's double braces, which Forge's interpolator does not read", () => {
|
|
155
|
+
// `translateForge` substitutes `{name}`. On `{{date}}` the regex matches
|
|
156
|
+
// the INNER braces, so the visitor saw "{Aug 20, 2026}" with a stray pair
|
|
157
|
+
// of braces around the value. Two keys shipped that way (copied from the
|
|
158
|
+
// admin bundle, which is i18next). The convention is single braces, and
|
|
159
|
+
// this pins it for every value in every bundle.
|
|
160
|
+
const offenders: string[] = [];
|
|
161
|
+
for (const [lang, bundle] of Object.entries(LOCALES)) {
|
|
162
|
+
for (const [key, value] of Object.entries(bundle)) {
|
|
163
|
+
if (value.includes("{{") || value.includes("}}")) offenders.push(`${lang}: ${key}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
expect(offenders, `Double-brace placeholders:\n${offenders.join("\n")}`).toEqual([]);
|
|
167
|
+
});
|
|
153
168
|
});
|