@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.34.0",
3
+ "version": "3.35.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -21,7 +21,8 @@
21
21
  "prepack": "npm run build:css",
22
22
  "check-types": "tsc --noEmit",
23
23
  "test": "vitest run",
24
- "test:watch": "vitest"
24
+ "test:watch": "vitest",
25
+ "lint": "eslint ."
25
26
  },
26
27
  "dependencies": {
27
28
  "@paystack/inline-js": "^2.24.0",
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { AxiosRequestConfig } from "axios";
3
+
4
+ import { createForgeClient } from "../createForgeClient";
5
+
6
+ /**
7
+ * WHICH site is asking, on every request.
8
+ *
9
+ * A profile can own several websites, and a deploy could only ever say whose
10
+ * tenant it belonged to, never which of that tenant's sites it was. Everything
11
+ * derived from the tenant alone therefore named the profile's PRIMARY site: a
12
+ * canonical URL, a share link, a link the backend puts in an email. The site
13
+ * serving the page and the site named in the link were different sites, and
14
+ * nothing errored.
15
+ *
16
+ * A header rather than a parameter because the fact is ambient: it is true of
17
+ * every request this deploy makes, so it cannot be forgotten by the next
18
+ * endpoint somebody adds.
19
+ */
20
+ function capture(options: Parameters<typeof createForgeClient>[0]) {
21
+ const sent: AxiosRequestConfig[] = [];
22
+ const client = createForgeClient(options);
23
+ // The request interceptor has already run by the time the adapter is called,
24
+ // so what lands here is what would have gone over the wire.
25
+ client.defaults.adapter = async (config) => {
26
+ sent.push(config);
27
+ return { data: {}, status: 200, statusText: "OK", headers: {}, config } as never;
28
+ };
29
+ return { client, sent };
30
+ }
31
+
32
+ const BASE = { baseURL: "https://api.example.test" };
33
+
34
+ describe("tenant identity headers", () => {
35
+ it("sends which website and which profile on every request", async () => {
36
+ const { client, sent } = capture({ ...BASE, websiteId: "web-1", profileId: "prof-1" });
37
+
38
+ await client.get("/public/anything");
39
+ await client.post("/public/something-else", {});
40
+
41
+ expect(sent).toHaveLength(2);
42
+ for (const config of sent) {
43
+ expect(config.headers?.["x-website-id"]).toBe("web-1");
44
+ expect(config.headers?.["x-profile-id"]).toBe("prof-1");
45
+ }
46
+ });
47
+
48
+ it("omits each header when the deploy does not know that value", async () => {
49
+ // A preview built from a bare profile has no website, and an older bundle
50
+ // has neither. Sending an empty header would make "unknown" and "empty
51
+ // string" indistinguishable to the server.
52
+ const { client, sent } = capture({ ...BASE, profileId: "prof-1" });
53
+
54
+ await client.get("/public/anything");
55
+
56
+ expect(sent[0]!.headers?.["x-profile-id"]).toBe("prof-1");
57
+ expect(sent[0]!.headers?.["x-website-id"]).toBeUndefined();
58
+ });
59
+
60
+ it("sends neither when the client is created without them", async () => {
61
+ const { client, sent } = capture(BASE);
62
+
63
+ await client.get("/public/anything");
64
+
65
+ expect(sent[0]!.headers?.["x-website-id"]).toBeUndefined();
66
+ expect(sent[0]!.headers?.["x-profile-id"]).toBeUndefined();
67
+ });
68
+
69
+ it("does not disturb the headers that already ride every request", async () => {
70
+ const { client, sent } = capture({ ...BASE, websiteId: "web-1", publishableKey: "pk_site_abc" });
71
+
72
+ await client.get("/public/anything");
73
+
74
+ expect(sent[0]!.headers?.["x-forge-key"]).toBe("pk_site_abc");
75
+ expect(sent[0]!.headers?.["x-timezone-offset"]).toBeDefined();
76
+ });
77
+ });
@@ -32,6 +32,29 @@ export interface ForgeClientOptions {
32
32
  * place (Component 1) this is sent on every request to scope reads to the tenant.
33
33
  */
34
34
  publishableKey?: string;
35
+ /**
36
+ * WHICH SITE this deploy is, sent as `x-website-id` on every request.
37
+ *
38
+ * A profile can own several websites, and until now a running site could not
39
+ * say which of them it was: the only tenant identity it carried was the
40
+ * profile's. Everything derived from that named the profile's primary site
41
+ * instead of the one serving the page, which is wrong for a canonical URL, a
42
+ * share link, or a link the backend puts in an email.
43
+ *
44
+ * Sent as a HEADER rather than threaded through call sites because it is
45
+ * ambient: it is true of every request this deploy makes, and a header cannot
46
+ * be forgotten by the next endpoint somebody adds.
47
+ */
48
+ websiteId?: string;
49
+ /**
50
+ * The tenant, sent as `x-profile-id`.
51
+ *
52
+ * Redundant today with `x-forge-key` where a publishable key is issued, and
53
+ * with the subdomain the request already arrives on. It rides along because
54
+ * neither of those is present on every deploy yet, and a request that cannot
55
+ * name its tenant is one the server has to infer it for.
56
+ */
57
+ profileId?: string;
35
58
  /** Enable 401-triggered refresh-and-retry for this client's auth lane. */
36
59
  refresh?: ForgeClientRefreshOptions;
37
60
  }
@@ -47,6 +70,8 @@ export const createForgeClient = ({
47
70
  baseURL,
48
71
  getToken,
49
72
  publishableKey,
73
+ websiteId,
74
+ profileId,
50
75
  refresh,
51
76
  }: ForgeClientOptions): AxiosInstance => {
52
77
  const client = axios.create({ baseURL });
@@ -62,6 +87,18 @@ export const createForgeClient = ({
62
87
  config.headers["x-forge-key"] = publishableKey;
63
88
  }
64
89
 
90
+ // Who this deploy is. Baked at publish, so they are the same on every
91
+ // request and cannot be spoofed into naming a tenant this site is not:
92
+ // the server still decides what a caller may see, and these only say which
93
+ // site is asking.
94
+ if (websiteId) {
95
+ config.headers["x-website-id"] = websiteId;
96
+ }
97
+
98
+ if (profileId) {
99
+ config.headers["x-profile-id"] = profileId;
100
+ }
101
+
65
102
  // First-touch funnel attribution. Rides every request rather than every
66
103
  // capture call, so a lead that arrives through a booking or a checkout is
67
104
  // attributed the same as one that arrives through a form.
@@ -0,0 +1,224 @@
1
+ import { useForge } from "../../provider/ForgeProvider";
2
+ import { useMutation } from "@tanstack/react-query";
3
+
4
+ /**
5
+ * The three write legs of watching an episode: open a session, keep it alive,
6
+ * and record where the fan got to.
7
+ *
8
+ * Everything load-bearing about protection is on the server and re-derived on
9
+ * every single request - the rental, the session holder and the token's episode
10
+ * binding are all re-checked when the key is fetched. Nothing in this file is a
11
+ * gate. It is the client half of a conversation whose answers are authoritative.
12
+ */
13
+
14
+ /** What the viewer overlay draws. Not a credential: it identifies, it does not authorize. */
15
+ export interface FilmWatermarkPayload {
16
+ name: string;
17
+ email: string;
18
+ /** Short reference tying a leak to a TRANSACTION, not just to an address someone could fake. */
19
+ rentalRef: string;
20
+ }
21
+
22
+ export interface FilmPlaybackEpisodeInfo {
23
+ id: string;
24
+ filmId: string;
25
+ seasonNumber: number;
26
+ episodeNumber: number;
27
+ title: string | null;
28
+ durationSec: number | null;
29
+ isFreePreview: boolean;
30
+ }
31
+
32
+ export interface FilmPlaybackRentalInfo {
33
+ id: string;
34
+ firstPlayedAt: string | null;
35
+ expiresAt: string | null;
36
+ durationHours: number;
37
+ }
38
+
39
+ /**
40
+ * One sidecar WebVTT track, as the session hands it over.
41
+ *
42
+ * `url` is ABSOLUTE and carries NO token, exactly like `masterUrl`. The token is
43
+ * appended by whoever renders the `<track>`, from whatever they hold at that
44
+ * moment, because the heartbeat rotates it: a token baked in server-side would
45
+ * be stale within ten minutes, and appending a second one would produce
46
+ * `?st=a&st=b`, which Express parses as an array and the schema rejects.
47
+ *
48
+ * Live tracks only. An archived one is absent rather than flagged, so no
49
+ * `<track>` can point at a 404.
50
+ */
51
+ export interface FilmSubtitleTrack {
52
+ id: string;
53
+ /** Canonical BCP-47, e.g. `en`, `pt-BR`, `zh-Hans`. Goes straight into `srclang`. */
54
+ language: string;
55
+ /** What the track picker shows. Never blank: the server falls back to the language tag. */
56
+ label: string;
57
+ isDefault: boolean;
58
+ url: string;
59
+ }
60
+
61
+ export interface FilmPlaybackSession {
62
+ sessionId: string;
63
+ /**
64
+ * Short-lived (~10 min), bound to `{rentalId, episodeId, sessionId}`, and
65
+ * refreshed by the heartbeat. It travels as `?st=` because HLS players cannot
66
+ * set headers on their own segment and key requests.
67
+ */
68
+ sessionToken: string;
69
+ /** Master playlist. Absolute when the API knows its own URL, root-relative otherwise. */
70
+ masterUrl: string;
71
+ keyUrl: string;
72
+ /** Sidecar WebVTT tracks, `isDefault` first then by language. Empty is normal. */
73
+ subtitles: FilmSubtitleTrack[];
74
+ watermark: FilmWatermarkPayload;
75
+ /** End of the rental window. Null for a free preview, which has no rental. */
76
+ expiresAt: string | null;
77
+ /** True only for the call that STARTED the window. Say so on screen: this is decision 12. */
78
+ clockStartedNow: boolean;
79
+ resumeAtSec: number;
80
+ heartbeatIntervalSec: number;
81
+ episode: FilmPlaybackEpisodeInfo;
82
+ /** Null for a free preview. */
83
+ rental: FilmPlaybackRentalInfo | null;
84
+ }
85
+
86
+ export interface FilmHeartbeatResult {
87
+ sessionId: string;
88
+ /** A FRESH token. The old one keeps working until its own expiry. */
89
+ sessionToken: string;
90
+ expiresAt: string | null;
91
+ heartbeatIntervalSec: number;
92
+ }
93
+
94
+ export interface FilmProgressResult {
95
+ /** False for a free preview: progress is keyed by rental and a preview has none. */
96
+ recorded: boolean;
97
+ positionSec: number;
98
+ completed: boolean;
99
+ }
100
+
101
+ /** Status off an axios-ish rejection, without importing axios or trusting its shape. */
102
+ export const filmErrorStatus = (error: unknown): number | null => {
103
+ const status = (error as { response?: { status?: unknown } } | null | undefined)?.response?.status;
104
+ return typeof status === "number" ? status : null;
105
+ };
106
+
107
+ /** The API's machine-readable reason, when it sent one. */
108
+ export const filmErrorCode = (error: unknown): string | null => {
109
+ const code = (error as { response?: { data?: { code?: unknown } } } | null | undefined)?.response?.data?.code;
110
+ return typeof code === "string" && code ? code : null;
111
+ };
112
+
113
+ /** The API's already-translated sentence, when it sent one. */
114
+ export const filmErrorMessage = (error: unknown): string | null => {
115
+ const message = (error as { response?: { data?: { message?: unknown } } } | null | undefined)?.response?.data
116
+ ?.message;
117
+ return typeof message === "string" && message.trim() ? message : null;
118
+ };
119
+
120
+ /**
121
+ * Another session took this rental over.
122
+ *
123
+ * One active session per rental, and a NEW session wins - takeover rather than
124
+ * rejection, so a crashed tab never locks the buyer out of what they paid for.
125
+ * The tab that lost is the one that gets this, and the honest thing to tell that
126
+ * person is "this is playing somewhere else", not "your rental is invalid".
127
+ */
128
+ export const FILM_SESSION_TAKEN_OVER = "FILM_SESSION_TAKEN_OVER";
129
+
130
+ /**
131
+ * The Redis holder lapsed - nobody took over, this tab simply stopped
132
+ * heartbeating (backgrounded, asleep, offline). Recoverable by opening a new
133
+ * session, and it must NOT be drawn as "playing on another device", which would
134
+ * be a lie the fan would act on.
135
+ */
136
+ export const FILM_SESSION_EXPIRED = "FILM_SESSION_EXPIRED";
137
+
138
+ const playbackBase = (filmId: string, episodeId: string) => `/public/films/${filmId}/episodes/${episodeId}`;
139
+
140
+ /**
141
+ * Open a playback session for one episode.
142
+ *
143
+ * Takes over any existing session FOR THAT RENTAL, including one on a different
144
+ * episode: the cap is one stream per rental, so switching from episode 1 to
145
+ * episode 2 replaces the session rather than opening a second one.
146
+ *
147
+ * The first successful call against any covered episode also starts the rental
148
+ * clock, and says so via `clockStartedNow`.
149
+ */
150
+ export function useCreateFilmPlaybackSession(filmId?: string, episodeId?: string) {
151
+ const { client } = useForge();
152
+
153
+ return useMutation<FilmPlaybackSession, unknown, void>({
154
+ mutationFn: async () => {
155
+ const res = await client.post(`${playbackBase(filmId as string, episodeId as string)}/playback-sessions`, {});
156
+ return res.data;
157
+ },
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Keep the session alive and discover a takeover.
163
+ *
164
+ * Both halves matter. An AES-128 player fetches the key once per playlist, so
165
+ * without this the Redis holder would lapse mid-film on a long episode; and a
166
+ * displaced tab has no other way to learn it was displaced, because nothing
167
+ * pushes to it.
168
+ */
169
+ export function useFilmPlaybackHeartbeat(filmId?: string, episodeId?: string) {
170
+ const { client } = useForge();
171
+
172
+ return useMutation<FilmHeartbeatResult, unknown, { sessionToken: string }>({
173
+ mutationFn: async ({ sessionToken }) => {
174
+ const res = await client.post(
175
+ `${playbackBase(filmId as string, episodeId as string)}/playback-sessions/heartbeat`,
176
+ { sessionToken },
177
+ );
178
+ return res.data;
179
+ },
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Record the resume position.
185
+ *
186
+ * Never trusted for entitlement, only for continue-watching and for the
187
+ * next-episode prompt, which is why it is safe to debounce hard and to drop
188
+ * silently on failure.
189
+ */
190
+ export function useRecordFilmProgress(filmId?: string, episodeId?: string) {
191
+ const { client } = useForge();
192
+
193
+ return useMutation<FilmProgressResult, unknown, { sessionToken: string; positionSec: number; completed?: boolean }>({
194
+ mutationFn: async (body) => {
195
+ const res = await client.put(`${playbackBase(filmId as string, episodeId as string)}/progress`, body);
196
+ return res.data;
197
+ },
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Turn a URL the API handed back into one this browser can fetch.
203
+ *
204
+ * The API returns a ROOT-RELATIVE path when it does not know its own public URL,
205
+ * and a root-relative path on a creator site resolves against the CREATOR's
206
+ * origin, not the API's. Left alone that produces a 404 on the site's own router
207
+ * rather than a manifest, which reads as "the player is broken".
208
+ */
209
+ export function absolutizeFilmUrl(url: string, apiUrl: string): string {
210
+ if (/^https?:\/\//i.test(url)) return url;
211
+ const base = apiUrl.replace(/\/+$/, "");
212
+ return `${base}${url.startsWith("/") ? "" : "/"}${url}`;
213
+ }
214
+
215
+ /**
216
+ * Attach the session token to a playback URL.
217
+ *
218
+ * `st` is the primary carrier because an HLS engine fetches manifests, keys and
219
+ * segments itself and cannot be made to send a header on those requests.
220
+ */
221
+ export function withFilmSessionToken(url: string, sessionToken: string): string {
222
+ const separator = url.includes("?") ? "&" : "?";
223
+ return `${url}${separator}st=${encodeURIComponent(sessionToken)}`;
224
+ }