@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.
- 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/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/usePaymentFlow.ts +28 -3
- package/src/i18n/de.json +130 -28
- package/src/i18n/en.json +130 -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/_tests/dialogPaystackStandDown.spec.tsx +185 -0
- package/src/ui/headless/dialog.tsx +139 -4
- 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 +21 -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 +101 -37
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +291 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
- package/src/ui/styled/forge-utilities.css +6 -0
- package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
- package/src/utils/paystackCheckout.ts +47 -0
- 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.
|
|
3
|
+
"version": "3.36.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,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The way back from a Paystack payment.
|
|
9
|
+
*
|
|
10
|
+
* Four checkouts (event tickets, courses, coaching, offers) build their finalise
|
|
11
|
+
* URL from an id that does not exist until the resource is created, so they hand
|
|
12
|
+
* it to `start({ …, returnUrl })` and never as a hook option. The Paystack
|
|
13
|
+
* session was assembled from the OPTION alone, so for all four it carried no
|
|
14
|
+
* return URL: the buyer paid, the popup closed, and the helper had nowhere to
|
|
15
|
+
* send them. They were left sitting on the checkout they had just paid on, with
|
|
16
|
+
* no receipt, no confirmation and no error — while the charge itself succeeded
|
|
17
|
+
* every time.
|
|
18
|
+
*
|
|
19
|
+
* Stripe never had this: it hands its own `return_url` to `confirmPayment`.
|
|
20
|
+
*
|
|
21
|
+
* The real helper is in the path here, with only `@paystack/inline-js` faked, so
|
|
22
|
+
* this asserts the navigation a fan actually gets rather than what was passed
|
|
23
|
+
* between two of our own functions.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type ResumeOptions = {
|
|
27
|
+
onSuccess?: (t: { reference?: string }) => void;
|
|
28
|
+
onCancel?: () => void;
|
|
29
|
+
onLoad?: (r: unknown) => void;
|
|
30
|
+
onError?: (e: unknown) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let captured: ResumeOptions = {};
|
|
34
|
+
|
|
35
|
+
vi.mock("@paystack/inline-js", () => ({
|
|
36
|
+
default: class FakePaystackPop {
|
|
37
|
+
resumeTransaction(_accessCode: string, options: ResumeOptions = {}) {
|
|
38
|
+
captured = options;
|
|
39
|
+
options.onLoad?.({});
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
cancelTransaction() {}
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const post = vi.fn();
|
|
47
|
+
|
|
48
|
+
vi.mock("../../../provider/ForgeProvider", () => ({
|
|
49
|
+
useForge: () => ({ client: { post }, profileId: "profile-1" }),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
import { usePaymentFlow } from "../usePaymentFlow";
|
|
53
|
+
|
|
54
|
+
const FINALISE = "https://artist.test/i/events/summer-show/finalise?orderId=order-1";
|
|
55
|
+
|
|
56
|
+
const wrapper = ({ children }: { children: ReactNode }) => {
|
|
57
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
58
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let location: { href: string };
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
captured = {};
|
|
65
|
+
post.mockReset();
|
|
66
|
+
post.mockResolvedValue({
|
|
67
|
+
data: { paymentProvider: "paystack", accessCode: "ac_1", checkoutUrl: "https://checkout.paystack.com/ac_1" },
|
|
68
|
+
});
|
|
69
|
+
location = { href: "https://artist.test/i/events/summer-show" };
|
|
70
|
+
Object.defineProperty(window, "location", { value: location, writable: true, configurable: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A ticket checkout: nothing is known until the order exists, so nothing is static. */
|
|
74
|
+
const useClickDrivenFlow = () => usePaymentFlow({ path: "/public/events/event-1/start-payment", autoStart: false });
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pay in the popup the flow opened for itself.
|
|
78
|
+
*
|
|
79
|
+
* `usePaymentFlow` auto-opens once a charge lands, which is what the ticket
|
|
80
|
+
* checkout relies on ("Paystack redirected" is its whole payment step), so the
|
|
81
|
+
* spec waits for that rather than racing it with a hand-driven open.
|
|
82
|
+
*/
|
|
83
|
+
async function payInPopup(reference = "pay-uuid"): Promise<void> {
|
|
84
|
+
await waitFor(() => expect(captured.onSuccess).toBeTypeOf("function"));
|
|
85
|
+
await act(async () => {
|
|
86
|
+
captured.onSuccess?.({ reference });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("usePaymentFlow: returning from a Paystack payment", () => {
|
|
91
|
+
it("REGRESSION: sends the buyer to the finalise URL the charge was started with", async () => {
|
|
92
|
+
const { result } = renderHook(useClickDrivenFlow, { wrapper });
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.start({ orderId: "order-1", returnUrl: FINALISE });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The server was told where to come back to...
|
|
99
|
+
expect(post).toHaveBeenCalledWith(
|
|
100
|
+
"/public/events/event-1/start-payment",
|
|
101
|
+
expect.objectContaining({ orderId: "order-1", returnUrl: FINALISE }),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await payInPopup();
|
|
105
|
+
|
|
106
|
+
// ...and so is the buyer. Before this fix `location.href` never moved.
|
|
107
|
+
expect(location.href).toContain("/finalise");
|
|
108
|
+
expect(location.href).toContain("orderId=order-1");
|
|
109
|
+
// The two params every finalise page already reads off a hosted redirect.
|
|
110
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
111
|
+
expect(location.href).toContain("trxref=pay-uuid");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("still honours a static returnUrl for the flows that have one up front", async () => {
|
|
115
|
+
// The cart, film rentals and payment links pass it as an option, and that
|
|
116
|
+
// path must not regress while the click-driven one is fixed.
|
|
117
|
+
const { result } = renderHook(
|
|
118
|
+
() => usePaymentFlow({ path: "/public/payment-links/link-1/start-payment", returnUrl: FINALISE, autoStart: false }),
|
|
119
|
+
{ wrapper },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
await result.current.start();
|
|
124
|
+
});
|
|
125
|
+
await payInPopup();
|
|
126
|
+
|
|
127
|
+
expect(location.href).toContain("reference=pay-uuid");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves navigation alone when the surface finalises in place", async () => {
|
|
131
|
+
// Donations show their own thank-you step, so they pass `onPaystackSuccess`
|
|
132
|
+
// and must NOT be navigated away from it.
|
|
133
|
+
const onPaystackSuccess = vi.fn();
|
|
134
|
+
const { result } = renderHook(
|
|
135
|
+
() =>
|
|
136
|
+
usePaymentFlow({ path: "/public/donations/d-1/start-payment", autoStart: false, onPaystackSuccess }),
|
|
137
|
+
{ wrapper },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
await result.current.start({ returnUrl: FINALISE });
|
|
142
|
+
});
|
|
143
|
+
await payInPopup();
|
|
144
|
+
|
|
145
|
+
expect(onPaystackSuccess).toHaveBeenCalledWith({ reference: "pay-uuid" });
|
|
146
|
+
expect(location.href).toBe("https://artist.test/i/events/summer-show");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("carries the LATEST start's URL when a charge is re-minted", async () => {
|
|
150
|
+
// A discount code re-starts the charge. Sending the buyer to the first
|
|
151
|
+
// attempt's finalise page would land them on a different order.
|
|
152
|
+
const { result } = renderHook(useClickDrivenFlow, { wrapper });
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await result.current.start({ orderId: "order-1", returnUrl: "https://artist.test/finalise?orderId=order-1" });
|
|
156
|
+
});
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await result.current.start({ orderId: "order-2", returnUrl: "https://artist.test/finalise?orderId=order-2" });
|
|
159
|
+
});
|
|
160
|
+
await payInPopup();
|
|
161
|
+
|
|
162
|
+
expect(location.href).toContain("orderId=order-2");
|
|
163
|
+
expect(location.href).not.toContain("orderId=order-1");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -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
|
+
}
|