@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,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
+ }
@@ -63,10 +63,32 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
63
63
  onError: opts.onPaystackError,
64
64
  };
65
65
 
66
+ /**
67
+ * The return URL the charge was actually STARTED with.
68
+ *
69
+ * Click-driven flows (event tickets, courses, coaching, offers) build their
70
+ * finalise URL from an id that does not exist until the resource is created,
71
+ * so they pass it in the `start()` override and never as a hook option. The
72
+ * Paystack session was read from the OPTION alone, so for all four it was
73
+ * `undefined`: the fan paid, the popup closed, `openPaystackCheckout` had
74
+ * nowhere to send them, and they sat on the checkout they had just paid on
75
+ * with no receipt and no confirmation. The charge was fine every time; only
76
+ * the way back was missing. Stripe was unaffected because it hands its own
77
+ * `return_url` to `confirmPayment`.
78
+ *
79
+ * A ref rather than state: it is read when the popup is opened, never
80
+ * rendered, and a re-render on every start would re-run the auto-open effect.
81
+ */
82
+ const startedReturnUrlRef = useRef<string | undefined>(undefined);
83
+
66
84
  const mutation = useMutation<PaymentFlowResult, unknown, Record<string, unknown> | undefined>({
67
85
  mutationFn: async (override) => {
68
86
  // override wins — click-driven flows pass a just-created orderId/returnUrl.
69
- const res = await client.post(path as string, { ...body, profileId, returnUrl, ...(override ?? {}) });
87
+ const payload = { ...body, profileId, returnUrl, ...(override ?? {}) };
88
+ // Whatever the server was told, byte for byte. Resolving it any other way
89
+ // is how these two drift apart again.
90
+ startedReturnUrlRef.current = (payload.returnUrl as string | undefined) || undefined;
91
+ const res = await client.post(path as string, payload);
70
92
  const data = res.data as PaymentStartResponse;
71
93
  const provider = (data.paymentProvider ?? data.paymentProviderName) as PaymentProviderName | undefined;
72
94
  return { ...data, provider };
@@ -88,8 +110,11 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
88
110
  * session, which is what makes "closed it by accident" recoverable.
89
111
  */
90
112
  const openPaystack = useCallback(async (): Promise<PaystackCheckoutOutcome> => {
91
- if (!hasPaystackSession(session)) return "unavailable";
92
- return openPaystackCheckout(session, {
113
+ // The URL the charge was started with wins over the static option: for a
114
+ // click-driven flow it is the only one there has ever been.
115
+ const active = { ...session, returnUrl: startedReturnUrlRef.current ?? session.returnUrl };
116
+ if (!hasPaystackSession(active)) return "unavailable";
117
+ return openPaystackCheckout(active, {
93
118
  onSuccess: handlersRef.current.onSuccess,
94
119
  onDismiss: handlersRef.current.onDismiss,
95
120
  onError: handlersRef.current.onError,