@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
@@ -27,6 +27,7 @@ import { AccountDashboard } from "../AccountDashboard";
27
27
  const PROFILE_ID = "profile-1";
28
28
  const ACCOUNT_ID = "account-1";
29
29
  const MINUTE = 60_000;
30
+ const HOUR = 60 * MINUTE;
30
31
 
31
32
  const member = {
32
33
  id: ACCOUNT_ID,
@@ -59,6 +60,9 @@ const booking = (overrides: Partial<MyBooking> = {}): MyBooking => {
59
60
  cancellationOutcome: null,
60
61
  createdAt: "2026-01-01T00:00:00.000Z",
61
62
  coachingBookingSlotId: "slot-1",
63
+ // The call's address, written when the booking was confirmed. A real
64
+ // confirmed video booking always carries one.
65
+ videoCallId: "call-1",
62
66
  sessionStartTime: start.toISOString(),
63
67
  sessionEndTime: end.toISOString(),
64
68
  coachingProductId: "product-1",
@@ -117,10 +121,34 @@ describe("AccountDashboard: the way into a coaching video call", () => {
117
121
 
118
122
  expect(html).toContain("Video session");
119
123
  expect(html).toContain("Join video call");
124
+ // A LINK to the call's own page, at the same address the coach opens from
125
+ // the dashboard. It used to mount the room inline, between two rows of a
126
+ // list, which is not what a call looks like anywhere else.
127
+ expect(html).toContain('href="/i/video-calls/call-1"');
120
128
  // The line still says where the session is; the control is in addition.
121
129
  expect(html).toContain("Video call on this site");
122
130
  });
123
131
 
132
+ it("draws no control for a session with no call address, but still says it is a video call", () => {
133
+ /**
134
+ * Only reachable for a booking confirmed before calls became addressable,
135
+ * since a confirmation now writes the record. A dead button would be worse
136
+ * than none, and the row above still names the session as a video call.
137
+ */
138
+ const html = render([booking({ videoCallId: null })]);
139
+
140
+ expect(html).toContain("Video call on this site");
141
+ expect(html).not.toContain("Join video call");
142
+ });
143
+
144
+ it("still says when the room opens even without a call address", () => {
145
+ // The window copy is about the SESSION, so it must not depend on whether a
146
+ // room record exists. This is the branch my first attempt broke.
147
+ const html = render([booking({ videoCallId: null, sessionStartTime: new Date(Date.now() + 6 * HOUR).toISOString() })]);
148
+
149
+ expect(html).toMatch(/opens|Opens/);
150
+ });
151
+
124
152
  it("says when the room opens, rather than offering a Join that would 409", () => {
125
153
  const start = new Date(Date.now() + 3 * 60 * MINUTE);
126
154
  const html = render([
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+ import type { MyFilmRental } from "../../../data/queries/useFilms";
8
+ import { AccountDashboard, type AccountTabKey } from "../AccountDashboard";
9
+
10
+ /**
11
+ * Rentals on the account page.
12
+ *
13
+ * A fan who rented a film could find their tickets, their orders and their
14
+ * coaching sessions on this page and no trace of the rental - `/i/films/library`
15
+ * held it, and nothing on the account page pointed there. So the tab exists, and
16
+ * these are the two things it must get right:
17
+ *
18
+ * LIVE - the window is open, so the row offers the way in (Resume when
19
+ * there is progress to resume, Watch otherwise).
20
+ * NOT LIVE - expired or refunded, so the row offers Rent again and NOT a
21
+ * Watch button that leads to a player which would refuse them.
22
+ *
23
+ * `isLive` is the SERVER's answer (`msRemaining`, `expiresAt`, `revokedAt` are
24
+ * all decided there). Nothing here recomputes it, and these specs assert the
25
+ * rendering of that flag rather than any clock arithmetic in the browser.
26
+ *
27
+ * Rendered through `react-dom/server`, like the community spec beside it: no
28
+ * DOM and no effects, so the rentals query is SEEDED into the react-query cache
29
+ * rather than mocked. That keeps the real `useMyFilmRentals` in the path, and a
30
+ * rename of its query key fails here.
31
+ */
32
+
33
+ const PROFILE_ID = "profile-1";
34
+ const ACCOUNT_ID = "account-1";
35
+
36
+ /** The key `useMyFilmRentals(accountId)` reads, at its default page and limit. */
37
+ const RENTALS_KEY = ["my-film-rentals", ACCOUNT_ID, PROFILE_ID, 1, 20];
38
+
39
+ const rental = (overrides: Partial<MyFilmRental> = {}): MyFilmRental => ({
40
+ id: "rental-1",
41
+ filmId: "film-1",
42
+ status: "active",
43
+ amountCents: 500,
44
+ currency: "USD",
45
+ rentalDurationHours: 48,
46
+ rentalOptionId: "option-1",
47
+ paidAt: "2026-08-01T00:00:00.000Z",
48
+ startBy: "2026-09-01T00:00:00.000Z",
49
+ firstPlayedAt: "2026-08-02T00:00:00.000Z",
50
+ expiresAt: "2026-08-04T00:00:00.000Z",
51
+ refundedAt: null,
52
+ revokedAt: null,
53
+ isLive: true,
54
+ msRemaining: 3 * 3600_000,
55
+ windowMs: 48 * 3600_000,
56
+ film: {
57
+ id: "film-1",
58
+ title: "The Long Goodbye",
59
+ slug: "the-long-goodbye",
60
+ kind: "film",
61
+ status: "published",
62
+ archivedAt: null,
63
+ cover: null,
64
+ },
65
+ progress: [],
66
+ ...overrides,
67
+ });
68
+
69
+ const fan = {
70
+ id: ACCOUNT_ID,
71
+ email: "fan@example.com",
72
+ firstName: "Fan",
73
+ lastName: "Person",
74
+ kind: "fan",
75
+ status: "active",
76
+ createdAt: "2026-01-01T00:00:00.000Z",
77
+ updatedAt: "2026-01-01T00:00:00.000Z",
78
+ membership: null,
79
+ };
80
+
81
+ function render(opts: { rentals: MyFilmRental[] | null; tab?: AccountTabKey }): string {
82
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
83
+ if (opts.rentals) {
84
+ queryClient.setQueryData(RENTALS_KEY, {
85
+ data: opts.rentals,
86
+ total: opts.rentals.length,
87
+ page: 1,
88
+ limit: 20,
89
+ });
90
+ }
91
+
92
+ return renderToStaticMarkup(
93
+ <QueryClientProvider client={queryClient}>
94
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
95
+ <PublicAuthContext.Provider
96
+ value={
97
+ {
98
+ user: fan,
99
+ isInitialized: true,
100
+ currencies: null,
101
+ userSelectedCurrency: "USD",
102
+ logout: () => {},
103
+ } as never
104
+ }
105
+ >
106
+ <ForgeThemeProvider
107
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
108
+ >
109
+ <AccountDashboard tab={opts.tab ?? "rentals"} onNavigateMembership={() => {}} />
110
+ </ForgeThemeProvider>
111
+ </PublicAuthContext.Provider>
112
+ </ForgeClientProvider>
113
+ </QueryClientProvider>,
114
+ );
115
+ }
116
+
117
+ describe("AccountDashboard: the fan's rentals", () => {
118
+ it("REGRESSION: a running rental is reachable from the account page", () => {
119
+ // The gap this closes. The rental existed, the player existed, and the
120
+ // account page mentioned neither.
121
+ const html = render({ rentals: [rental()] });
122
+
123
+ expect(html).toContain("Rentals");
124
+ expect(html).toContain("The Long Goodbye");
125
+ expect(html).toContain("Watch");
126
+ // The window is state the fan acts on, so it is on the row rather than
127
+ // behind a `?`.
128
+ expect(html).toContain("3h 0m left");
129
+ expect(html).not.toContain("Rent again");
130
+ });
131
+
132
+ it("offers to resume the episode the fan stopped in the middle of", () => {
133
+ const html = render({
134
+ rentals: [rental({ progress: [{ episodeId: "episode-1", positionSec: 900, completedAt: null }] })],
135
+ });
136
+
137
+ expect(html).toContain("Resume");
138
+ });
139
+
140
+ it("REGRESSION: an expired rental offers Rent again, never Watch", () => {
141
+ // Drawing Watch here walks the fan into a player that refuses them: the
142
+ // window is the server's to judge and it has closed.
143
+ const html = render({
144
+ rentals: [rental({ isLive: false, msRemaining: 0, expiresAt: "2026-08-04T00:00:00.000Z" })],
145
+ });
146
+
147
+ expect(html).toContain("Rent again");
148
+ expect(html).toContain("Your window has closed.");
149
+ expect(html).not.toContain(">Watch<");
150
+ expect(html).not.toContain(">Resume<");
151
+ });
152
+
153
+ it("says a refunded rental was refunded rather than that it ran out", () => {
154
+ // Two different things happened, and "your window has closed" over a refund
155
+ // reads as the artist keeping the money.
156
+ const html = render({
157
+ rentals: [rental({ isLive: false, msRemaining: null, refundedAt: "2026-08-03T00:00:00.000Z" })],
158
+ });
159
+
160
+ expect(html).toContain("Refunded.");
161
+ expect(html).toContain("Rent again");
162
+ });
163
+
164
+ it("keeps a rental that has not been started apart from one that is running", () => {
165
+ // The clock starts at first play, so this fan has lost nothing by waiting.
166
+ // Saying "0m left" here would be a lie that costs them the film.
167
+ const html = render({ rentals: [rental({ firstPlayedAt: null, msRemaining: null, expiresAt: null })] });
168
+
169
+ expect(html).toContain("Not started.");
170
+ expect(html).toContain("Watch");
171
+ });
172
+
173
+ it("does not draw the tab on a site where this fan holds no rental", () => {
174
+ // Film rentals are off by default and per profile. An always-empty tab on
175
+ // every account page is noise on every site that never sells one.
176
+ const html = render({ rentals: [], tab: "membership" });
177
+
178
+ expect(html).not.toContain("Rentals");
179
+ // The rest of the strip is untouched, so this is the one tab missing rather
180
+ // than the dashboard failing to draw.
181
+ expect(html).toContain("Tickets");
182
+ });
183
+
184
+ it("stays quiet while the rentals query has not answered yet", () => {
185
+ // First paint has no cached answer. Drawing the tab on a guess flashes it
186
+ // and then withdraws it from fans who never rented anything.
187
+ const html = render({ rentals: null, tab: "membership" });
188
+
189
+ expect(html).not.toContain("Rentals");
190
+ });
191
+
192
+ it("keeps the tab drawn when it is the one being shown, even with nothing in it", () => {
193
+ // A receipt email links straight at `?tab=rentals`. A strip that disowns the
194
+ // panel underneath it is worse than an empty tab.
195
+ const html = render({ rentals: [], tab: "rentals" });
196
+
197
+ expect(html).toContain("Rentals");
198
+ expect(html).toContain("You have not rented anything yet.");
199
+ });
200
+ });
@@ -84,6 +84,9 @@
84
84
  .order-1 {
85
85
  order: 1;
86
86
  }
87
+ .order-2 {
88
+ order: 2;
89
+ }
87
90
  .container {
88
91
  width: 100%;
89
92
  @media (width >= 40rem) {
@@ -204,6 +207,9 @@
204
207
  .shrink-0 {
205
208
  flex-shrink: 0;
206
209
  }
210
+ .grow {
211
+ flex-grow: 1;
212
+ }
207
213
  .transform {
208
214
  transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
209
215
  }
@@ -48,7 +48,12 @@ vi.mock("@paystack/inline-js", () => {
48
48
 
49
49
  // Imported after the mock is registered (vitest hoists `vi.mock`, so a plain
50
50
  // top-level import would be fine; this is only for readability).
51
- import { openPaystackCheckout, paystackReturnUrl, hasPaystackSession } from "../paystackCheckout";
51
+ import {
52
+ openPaystackCheckout,
53
+ paystackReturnUrl,
54
+ hasPaystackSession,
55
+ isPaystackPopupOpen,
56
+ } from "../paystackCheckout";
52
57
 
53
58
  const HOSTED = "https://checkout.paystack.com/abc123";
54
59
  const RETURN = "https://artist.test/checkout/finalise?orderId=order-1";
@@ -264,3 +269,79 @@ describe("openPaystackCheckout", () => {
264
269
  expect(location.href).toBe("https://artist.test/checkout");
265
270
  });
266
271
  });
272
+
273
+ /**
274
+ * The popup says when it is up, so the dialog it was opened from can stand
275
+ * down (`ui/headless/dialog.tsx`).
276
+ *
277
+ * A flag left stuck ON is worse than no flag: every dialog on the site would
278
+ * stay non-modal for the rest of the session. So each terminal outcome is
279
+ * asserted separately rather than trusting one shared teardown.
280
+ */
281
+ describe("openPaystackCheckout popup state", () => {
282
+ it("is closed before anything is opened", () => {
283
+ expect(isPaystackPopupOpen()).toBe(false);
284
+ });
285
+
286
+ it("is open while the fan is deciding, and closed again once they pay", async () => {
287
+ let captured: ResumeOptions = {};
288
+ resumeBehaviour = (_code, options) => {
289
+ captured = options;
290
+ options.onLoad?.({});
291
+ };
292
+
293
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
294
+ await vi.advanceTimersByTimeAsync(0);
295
+
296
+ // The card form is on screen: the dialog behind it must not be holding the
297
+ // page's pointer events hostage.
298
+ expect(isPaystackPopupOpen()).toBe(true);
299
+
300
+ captured.onSuccess?.({ reference: "pay-uuid" });
301
+ await pending;
302
+
303
+ expect(isPaystackPopupOpen()).toBe(false);
304
+ });
305
+
306
+ it("closes on a dismissal, so the fan gets a normal dialog back", async () => {
307
+ resumeBehaviour = (_code, options) => {
308
+ options.onLoad?.({});
309
+ options.onCancel?.();
310
+ };
311
+
312
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
313
+
314
+ expect(isPaystackPopupOpen()).toBe(false);
315
+ });
316
+
317
+ it("closes on a provider failure", async () => {
318
+ resumeBehaviour = (_code, options) => {
319
+ options.onLoad?.({});
320
+ options.onError?.({ message: "Card declined" });
321
+ };
322
+
323
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED }, { onError: vi.fn() });
324
+
325
+ expect(isPaystackPopupOpen()).toBe(false);
326
+ });
327
+
328
+ it("closes when the popup never appears and the fan is sent to the hosted page", async () => {
329
+ resumeBehaviour = () => {};
330
+
331
+ const pending = openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
332
+ await vi.advanceTimersByTimeAsync(5000);
333
+ await pending;
334
+
335
+ expect(isPaystackPopupOpen()).toBe(false);
336
+ });
337
+
338
+ it("closes when resumeTransaction throws before the popup exists", async () => {
339
+ resumeBehaviour = () => {
340
+ throw new Error("popup blew up");
341
+ };
342
+
343
+ await openPaystackCheckout({ accessCode: "ac_1", checkoutUrl: HOSTED, returnUrl: RETURN });
344
+
345
+ expect(isPaystackPopupOpen()).toBe(false);
346
+ });
347
+ });
@@ -129,6 +129,45 @@ const modalIsPresent = (): boolean => {
129
129
  });
130
130
  };
131
131
 
132
+ /**
133
+ * Is a Paystack popup on screen right now?
134
+ *
135
+ * Paystack appends its overlay to `document.body`, which puts it OUTSIDE any
136
+ * dialog the pay button was rendered in. A modal dialog is built to make
137
+ * exactly that unreachable: Radix sets `pointer-events: none` on the body and
138
+ * traps focus inside its own content, so the popup renders on top, looks
139
+ * usable, and swallows every click. A fan on a code website could not pay for
140
+ * tickets, a course, a coaching slot, a film, a donation or an offer, because
141
+ * all six open Paystack from inside a dialog.
142
+ *
143
+ * So the popup announces itself and the dialog primitives get out of the way
144
+ * (`ui/headless/dialog.tsx`). It is published from HERE rather than from a
145
+ * React module because this file is the one funnel every pillar already goes
146
+ * through, and a second place that knows when the popup is up is a second place
147
+ * to forget to clear it.
148
+ */
149
+ let popupOpen = false;
150
+ const popupListeners = new Set<() => void>();
151
+
152
+ /** For `useSyncExternalStore`, and for anything else that needs the answer now. */
153
+ export function isPaystackPopupOpen(): boolean {
154
+ return popupOpen;
155
+ }
156
+
157
+ /** Subscribe to popup open/close. Returns the unsubscribe. */
158
+ export function subscribeToPaystackPopup(listener: () => void): () => void {
159
+ popupListeners.add(listener);
160
+ return () => {
161
+ popupListeners.delete(listener);
162
+ };
163
+ }
164
+
165
+ const setPopupOpen = (next: boolean): void => {
166
+ if (popupOpen === next) return;
167
+ popupOpen = next;
168
+ for (const listener of popupListeners) listener();
169
+ };
170
+
132
171
  const errorMessageOf = (error: unknown): string | undefined => {
133
172
  if (typeof error === "string") return error;
134
173
  const message = (error as { message?: unknown } | null)?.message;
@@ -222,6 +261,10 @@ export async function openPaystackCheckout(
222
261
  if (settled) return false;
223
262
  settled = true;
224
263
  stopWatching();
264
+ // Every terminal outcome passes through here, which is what makes "the
265
+ // popup is up" impossible to leave stuck on: paid, dismissed, failed and
266
+ // fell-back-to-the-hosted-page all clear it.
267
+ setPopupOpen(false);
225
268
  resolve(outcome);
226
269
  return true;
227
270
  };
@@ -237,6 +280,10 @@ export async function openPaystackCheckout(
237
280
  };
238
281
 
239
282
  try {
283
+ // Announced BEFORE the iframe exists, not on `onLoad`. The dialog around
284
+ // the pay button has to have released the page by the time the popup is
285
+ // painted, and a fan taps the first card field the instant they see it.
286
+ setPopupOpen(true);
240
287
  new PaystackPop().resumeTransaction(accessCode, {
241
288
  onSuccess: (transaction) => succeed(transaction ?? {}),
242
289
  // Closing the popup leaves the charge exactly where it was. Nothing is
@@ -10,22 +10,15 @@ export function buildOrganizationSchema(webPage: WebPage, origin: string) {
10
10
  "@type": "Organization",
11
11
  name: webPage.profile.name,
12
12
  url: origin,
13
- ...(webPage.themeSettings.header?.logo
14
- ? { logo: webPage.themeSettings.header.logo }
15
- : {}),
16
- ...(socialLinks.length > 0
17
- ? { sameAs: socialLinks.map((l: { href: string }) => l.href).filter(Boolean) }
18
- : {}),
13
+ ...(webPage.themeSettings.header?.logo ? { logo: webPage.themeSettings.header.logo } : {}),
14
+ ...(socialLinks.length > 0 ? { sameAs: socialLinks.map((l: { href: string }) => l.href).filter(Boolean) } : {}),
19
15
  };
20
16
  }
21
17
 
22
18
  /**
23
19
  * Generates WebPage JSON-LD.
24
20
  */
25
- export function buildWebPageSchema(
26
- webPage: WebPage,
27
- canonicalUrl: string,
28
- ) {
21
+ export function buildWebPageSchema(webPage: WebPage, canonicalUrl: string) {
29
22
  return {
30
23
  "@context": "https://schema.org",
31
24
  "@type": "WebPage",
@@ -181,10 +174,7 @@ export function buildEntitySchema(input: EntitySchemaInput): Record<string, unkn
181
174
 
182
175
  const { aggregate } = input;
183
176
  const hasRatings =
184
- !!aggregate &&
185
- aggregate.reviewCount > 0 &&
186
- Number.isFinite(aggregate.avgRating) &&
187
- aggregate.avgRating > 0;
177
+ !!aggregate && aggregate.reviewCount > 0 && Number.isFinite(aggregate.avgRating) && aggregate.avgRating > 0;
188
178
 
189
179
  const reviews = (input.reviews ?? [])
190
180
  .filter((r) => r.rating >= 1 && r.rating <= 5)
@@ -439,12 +429,93 @@ export function buildServiceSchema(
439
429
  aggregate: service.reviewAggregate ?? null,
440
430
  reviews: options.reviews,
441
431
  offer:
442
- options.currency && price !== undefined
443
- ? { currency: options.currency, price, availability: "InStock" }
444
- : null,
432
+ options.currency && price !== undefined ? { currency: options.currency, price, availability: "InStock" } : null,
445
433
  });
446
434
  }
447
435
 
436
+ /**
437
+ * Structural view of `GET /public/films/:slug`. `PublicFilmDetail` satisfies it.
438
+ *
439
+ * Deliberately narrow, and deliberately NOT the whole payload: JSON-LD is
440
+ * emitted into an anonymous SSR document, so anything named here is something a
441
+ * crawler and a signed-out visitor both get to read. Nothing about a rental,
442
+ * a session or an episode's storage belongs in it.
443
+ */
444
+ export interface FilmSchemaSource {
445
+ kind: "film" | "series";
446
+ title: string;
447
+ description?: string | null;
448
+ cover?: { url: string } | null;
449
+ currency: string;
450
+ rentalPriceCents?: number | null;
451
+ publishedAt?: string | null;
452
+ episodes?: { durationSec?: number | null }[] | null;
453
+ }
454
+
455
+ /** ISO 8601 duration (`PT1H42M`) from whole seconds, or undefined when unknown. */
456
+ const isoDuration = (seconds?: number | null): string | undefined => {
457
+ if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return undefined;
458
+ const whole = Math.floor(seconds);
459
+ const hours = Math.floor(whole / 3600);
460
+ const minutes = Math.round((whole % 3600) / 60);
461
+ if (!hours && !minutes) return undefined;
462
+ return `PT${hours ? `${hours}H` : ""}${minutes ? `${minutes}M` : ""}`;
463
+ };
464
+
465
+ /**
466
+ * schema.org/Movie or /TVSeries for a rentable title.
467
+ *
468
+ * Built by hand rather than through `buildEntitySchema`, whose `type` union is
469
+ * closed over the three reviewable sellables. Widening that union to carry two
470
+ * types that have no review surface would make every caller of it responsible
471
+ * for cases it can never see.
472
+ *
473
+ * The offer is the title's rental price, which is the only price there is: a
474
+ * rental always buys the whole title. There is no cheaper way in to quote, and
475
+ * a title with no price gets no `offers` node rather than a free one.
476
+ */
477
+ export function buildFilmSchema(
478
+ film: FilmSchemaSource | null | undefined,
479
+ options: SellableSchemaOptions = {},
480
+ ): Record<string, unknown> | null {
481
+ const name = film?.title?.trim();
482
+ if (!film || !name) return null;
483
+
484
+ const priceCents =
485
+ typeof film.rentalPriceCents === "number" && Number.isFinite(film.rentalPriceCents) ? film.rentalPriceCents : null;
486
+ const currency = options.currency ?? film.currency;
487
+ const description = toSchemaText(film.description);
488
+ // A film's runtime is its single episode's. A series has no one runtime, so it
489
+ // gets none rather than an invented average.
490
+ const duration = film.kind === "film" ? isoDuration(film.episodes?.[0]?.durationSec) : undefined;
491
+
492
+ return {
493
+ "@context": "https://schema.org",
494
+ "@type": film.kind === "series" ? "TVSeries" : "Movie",
495
+ name,
496
+ ...(description ? { description } : {}),
497
+ ...(film.cover?.url ? { image: film.cover.url } : {}),
498
+ ...(options.url ? { url: options.url } : {}),
499
+ ...(options.brand ? { provider: { "@type": "Organization", name: options.brand } } : {}),
500
+ ...(film.publishedAt ? { datePublished: film.publishedAt } : {}),
501
+ ...(duration ? { duration } : {}),
502
+ ...(film.kind === "series" && film.episodes?.length ? { numberOfEpisodes: film.episodes.length } : {}),
503
+ ...(currency && priceCents != null
504
+ ? {
505
+ offers: {
506
+ "@type": "Offer",
507
+ priceCurrency: currency,
508
+ price: money(priceCents / 100),
509
+ availability: "https://schema.org/InStock",
510
+ // A rental, not a sale. Saying so is what keeps a $2 line from
511
+ // reading as the price of owning the film.
512
+ category: "Rental",
513
+ },
514
+ }
515
+ : {}),
516
+ };
517
+ }
518
+
448
519
  /**
449
520
  * The `scripts` entry a TanStack `head()` needs for a JSON-LD node. Returns an
450
521
  * empty array for a null schema, so it can be spread unconditionally: