@tribe-nest/forge 3.34.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 (33) 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/useFilmPlaybackSession.ts +224 -0
  5. package/src/data/queries/useFilms.ts +561 -0
  6. package/src/i18n/de.json +128 -28
  7. package/src/i18n/en.json +128 -28
  8. package/src/index.ts +5 -0
  9. package/src/provider/ForgeAppProvider.tsx +10 -0
  10. package/src/provider/ForgeProvider.tsx +18 -3
  11. package/src/server/index.ts +76 -17
  12. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  13. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  14. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  15. package/src/ui/headless/film/index.ts +36 -0
  16. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  17. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  18. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  19. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  20. package/src/ui/headless/index.ts +6 -9
  21. package/src/ui/index.ts +18 -16
  22. package/src/ui/media/CallStage.tsx +59 -1
  23. package/src/ui/media/CallWindowNotice.tsx +94 -0
  24. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  25. package/src/ui/media/index.ts +9 -0
  26. package/src/ui/styled/AccountDashboard.tsx +28 -36
  27. package/src/ui/styled/FilmCatalog.tsx +278 -0
  28. package/src/ui/styled/FilmDetail.tsx +661 -0
  29. package/src/ui/styled/FilmLibrary.tsx +254 -0
  30. package/src/ui/styled/FilmWatch.tsx +701 -0
  31. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  32. package/src/ui/styled/forge-utilities.css +3 -0
  33. 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([
@@ -204,6 +204,9 @@
204
204
  .shrink-0 {
205
205
  flex-shrink: 0;
206
206
  }
207
+ .grow {
208
+ flex-grow: 1;
209
+ }
207
210
  .transform {
208
211
  transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
209
212
  }
@@ -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: