@tribe-nest/forge 3.31.0 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. package/src/utils/structuredData.ts +88 -17
@@ -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: