@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.
- package/package.json +3 -2
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/i18n/de.json +128 -28
- package/src/i18n/en.json +128 -28
- 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 +18 -16
- package/src/ui/media/CallStage.tsx +59 -1
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +28 -36
- 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 +28 -0
- package/src/ui/styled/forge-utilities.css +3 -0
- 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([
|
|
@@ -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
|
-
|
|
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:
|