@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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a call page shows when there is no call to join yet, or any more.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not the connection status
|
|
10
|
+
*
|
|
11
|
+
* A page that mounts `<CallStage>` regardless of the window tries to connect,
|
|
12
|
+
* fails, and reports the only thing it knows: "not yet connected". That is true
|
|
13
|
+
* and useless. Somebody who followed a link from their confirmation email is
|
|
14
|
+
* asking one of two questions, and the server already answered both in the same
|
|
15
|
+
* read that produced this page: WHEN can I come in, and HAVE I missed it.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the times are rendered, not the raw window
|
|
18
|
+
*
|
|
19
|
+
* `opensAt` is the server's own boundary, the same one it enforces when it
|
|
20
|
+
* refuses a ticket, so what this prints is what the server will honour rather
|
|
21
|
+
* than a client-side guess. It is formatted in the reader's locale and, where
|
|
22
|
+
* the session carries one, the session's timezone: a coaching client in Lagos
|
|
23
|
+
* looking at a Berlin coach's session should be told the time they will
|
|
24
|
+
* actually be sitting at their desk.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT a countdown. A ticking clock invites somebody to sit and
|
|
27
|
+
* watch it, and the page is already re-fetched when they return.
|
|
28
|
+
*/
|
|
29
|
+
export type CallWindowNoticeProps = {
|
|
30
|
+
/** The earliest the server will mint a ticket. */
|
|
31
|
+
opensAt: string | Date;
|
|
32
|
+
/** After this it refuses new tickets; anyone already inside stays inside. */
|
|
33
|
+
closesAt: string | Date;
|
|
34
|
+
/** The call's own title, so the reader knows which session this is. */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** IANA zone the session was sold in, when there is one. */
|
|
37
|
+
timeZone?: string | null;
|
|
38
|
+
/** Injected by specs; defaults to the clock. */
|
|
39
|
+
now?: Date;
|
|
40
|
+
/** A way back, rendered under the message when given. */
|
|
41
|
+
action?: ReactNode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function CallWindowNotice({ opensAt, closesAt, title, timeZone, now, action }: CallWindowNoticeProps) {
|
|
45
|
+
const t = useForgeT();
|
|
46
|
+
const tokens = useThemeTokens();
|
|
47
|
+
|
|
48
|
+
const opens = new Date(opensAt);
|
|
49
|
+
const closes = new Date(closesAt);
|
|
50
|
+
const at = now ?? new Date();
|
|
51
|
+
|
|
52
|
+
const when = opens.toLocaleString(undefined, {
|
|
53
|
+
weekday: "short",
|
|
54
|
+
day: "numeric",
|
|
55
|
+
month: "short",
|
|
56
|
+
hour: "numeric",
|
|
57
|
+
minute: "2-digit",
|
|
58
|
+
timeZone: timeZone ?? undefined,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Three states, and the middle one is the reason this component exists: a
|
|
62
|
+
// call that is open but unreachable is the connection status's business, not
|
|
63
|
+
// this component's, so it renders nothing and lets `<CallStage>` speak.
|
|
64
|
+
const tooEarly = at < opens;
|
|
65
|
+
const tooLate = at > closes;
|
|
66
|
+
if (!tooEarly && !tooLate) return null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
style={{
|
|
71
|
+
height: "100%",
|
|
72
|
+
display: "flex",
|
|
73
|
+
flexDirection: "column",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
gap: "0.5rem",
|
|
77
|
+
padding: "2rem 1.5rem",
|
|
78
|
+
textAlign: "center",
|
|
79
|
+
color: tokens.text,
|
|
80
|
+
backgroundColor: tokens.background,
|
|
81
|
+
borderRadius: tokens.cornerRadius,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{title && <div style={{ fontWeight: 600, fontSize: "1.125rem" }}>{title}</div>}
|
|
85
|
+
<div style={{ fontSize: "1rem" }}>
|
|
86
|
+
{tooEarly ? t("forge.call_window.not_started") : t("forge.call_window.ended")}
|
|
87
|
+
</div>
|
|
88
|
+
<div style={{ fontSize: "0.875rem", opacity: 0.7, maxWidth: "32rem" }}>
|
|
89
|
+
{tooEarly ? t("forge.call_window.opens_at", { datetime: when }) : t("forge.call_window.ended_detail")}
|
|
90
|
+
</div>
|
|
91
|
+
{action && <div style={{ marginTop: "0.75rem" }}>{action}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
|
|
4
|
+
import { ForgeI18nProvider } from "../../../i18n";
|
|
5
|
+
import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
6
|
+
import { CallWindowNotice } from "../CallWindowNotice";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The state a call page is in for most of a session's life: booked, not yet
|
|
10
|
+
* open, or long over.
|
|
11
|
+
*
|
|
12
|
+
* Every surface used to mount the call regardless and report "not yet
|
|
13
|
+
* connected", which is the connection's answer to a question nobody asked. The
|
|
14
|
+
* two questions somebody following a link from their confirmation email is
|
|
15
|
+
* actually asking are WHEN can I come in and HAVE I missed it, and the server
|
|
16
|
+
* sends both boundaries with the page.
|
|
17
|
+
*/
|
|
18
|
+
const theme: ForgeTheme = {
|
|
19
|
+
colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" },
|
|
20
|
+
cornerRadius: 10,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const MINUTE = 60_000;
|
|
24
|
+
const HOUR = 60 * MINUTE;
|
|
25
|
+
|
|
26
|
+
const render = (props: { opensAt: Date; closesAt: Date; now: Date; locale?: string }) =>
|
|
27
|
+
renderToStaticMarkup(
|
|
28
|
+
<ForgeI18nProvider locale={props.locale ?? "en"}>
|
|
29
|
+
<ForgeThemeProvider theme={theme}>
|
|
30
|
+
<CallWindowNotice
|
|
31
|
+
title="1:1 Coaching"
|
|
32
|
+
opensAt={props.opensAt.toISOString()}
|
|
33
|
+
closesAt={props.closesAt.toISOString()}
|
|
34
|
+
now={props.now}
|
|
35
|
+
/>
|
|
36
|
+
</ForgeThemeProvider>
|
|
37
|
+
</ForgeI18nProvider>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
describe("CallWindowNotice", () => {
|
|
41
|
+
const now = new Date("2026-08-23T12:00:00.000Z");
|
|
42
|
+
|
|
43
|
+
it("says the call has not started, and when to come back", () => {
|
|
44
|
+
const html = render({ opensAt: new Date(now.getTime() + 6 * HOUR), closesAt: new Date(now.getTime() + 9 * HOUR), now });
|
|
45
|
+
|
|
46
|
+
expect(html).toContain("has not started yet");
|
|
47
|
+
expect(html).toContain("You can join from");
|
|
48
|
+
// The session it belongs to, so a reader with two bookings knows which.
|
|
49
|
+
expect(html).toContain("1:1 Coaching");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("says the call has ended once the window is past", () => {
|
|
53
|
+
const html = render({ opensAt: new Date(now.getTime() - 4 * HOUR), closesAt: new Date(now.getTime() - HOUR), now });
|
|
54
|
+
|
|
55
|
+
expect(html).toContain("has ended");
|
|
56
|
+
expect(html).not.toContain("You can join from");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders NOTHING while the room is open, so the call speaks for itself", () => {
|
|
60
|
+
// The one state this component must stay out of: a call that is open but
|
|
61
|
+
// unreachable is the connection status's business.
|
|
62
|
+
const html = render({ opensAt: new Date(now.getTime() - MINUTE), closesAt: new Date(now.getTime() + HOUR), now });
|
|
63
|
+
|
|
64
|
+
// The provider still emits its own <style> tag, so this asserts the absence
|
|
65
|
+
// of the NOTICE rather than of all output.
|
|
66
|
+
expect(html).not.toContain("has not started");
|
|
67
|
+
expect(html).not.toContain("has ended");
|
|
68
|
+
expect(html).not.toContain("1:1 Coaching");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("speaks the visitor's language", () => {
|
|
72
|
+
const html = render({
|
|
73
|
+
opensAt: new Date(now.getTime() + 6 * HOUR),
|
|
74
|
+
closesAt: new Date(now.getTime() + 9 * HOUR),
|
|
75
|
+
now,
|
|
76
|
+
locale: "de",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The reported symptom was a German visitor being told only "noch nicht
|
|
80
|
+
// verbunden". They now get the answer, in German.
|
|
81
|
+
expect(html).toContain("noch nicht begonnen");
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/ui/media/index.ts
CHANGED
|
@@ -93,6 +93,15 @@ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protoco
|
|
|
93
93
|
* for both.
|
|
94
94
|
*/
|
|
95
95
|
export { CallStage, type CallStageProps } from "./CallStage";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a call page shows OUTSIDE the window, when there is nothing to connect
|
|
99
|
+
* to. Exported because all three surfaces (admin, the client portal and a
|
|
100
|
+
* creator site) reached the same dead end: they mounted the call regardless,
|
|
101
|
+
* failed to connect, and reported "not yet connected", which answers neither of
|
|
102
|
+
* the two questions somebody arriving from an email is asking.
|
|
103
|
+
*/
|
|
104
|
+
export { CallWindowNotice, type CallWindowNoticeProps } from "./CallWindowNotice";
|
|
96
105
|
export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
|
|
97
106
|
export {
|
|
98
107
|
callHeadCount,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
usePublicAuth,
|
|
4
4
|
useUserOrders,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
useSavedPosts,
|
|
19
19
|
useNotificationPreferences,
|
|
20
20
|
useUpdateNotificationPreference,
|
|
21
|
+
useMyFilmRentals,
|
|
21
22
|
useCancelMembership,
|
|
22
23
|
useMembershipAccess,
|
|
23
24
|
useOpenBillingPortal,
|
|
@@ -42,6 +43,7 @@ import { AddToCalendar } from "./AddToCalendar";
|
|
|
42
43
|
import { WalletPassButtons } from "./WalletPassButtons";
|
|
43
44
|
import { TicketTransferPanel } from "./TicketTransfer";
|
|
44
45
|
import { CartLineOptions } from "./CartLineOptions";
|
|
46
|
+
import { FilmRentalList } from "./FilmLibrary";
|
|
45
47
|
|
|
46
48
|
export const ACCOUNT_TABS = [
|
|
47
49
|
"membership",
|
|
@@ -54,6 +56,13 @@ export const ACCOUNT_TABS = [
|
|
|
54
56
|
// "order" — the Orders tab covers product orders only — so it needs its own
|
|
55
57
|
// tab or it is unreachable, which is what it was.
|
|
56
58
|
"bookings",
|
|
59
|
+
// Films and series this fan has rented. Next to the other purchase surfaces
|
|
60
|
+
// because it answers the same question they do: what did I buy from this
|
|
61
|
+
// artist, and can I use it right now?
|
|
62
|
+
//
|
|
63
|
+
// Drawn only when the fan actually holds a rental (see `visibleTabs`), so the
|
|
64
|
+
// tab does not appear on the account page of every site that never sells one.
|
|
65
|
+
"rentals",
|
|
57
66
|
"saved",
|
|
58
67
|
"account",
|
|
59
68
|
"notifications",
|
|
@@ -70,6 +79,7 @@ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
|
|
|
70
79
|
tickets: "forge.account_dashboard.tab_tickets",
|
|
71
80
|
waitlist: "forge.account_dashboard.tab_waitlist",
|
|
72
81
|
bookings: "forge.account_dashboard.tab_bookings",
|
|
82
|
+
rentals: "forge.account_dashboard.tab_rentals",
|
|
73
83
|
saved: "forge.account_dashboard.tab_saved",
|
|
74
84
|
account: "forge.account_dashboard.tab_account",
|
|
75
85
|
notifications: "forge.account_dashboard.tab_notifications",
|
|
@@ -98,6 +108,15 @@ export interface AccountDashboardProps {
|
|
|
98
108
|
* member a room they get bounced out of, which is worse than no link at all.
|
|
99
109
|
*/
|
|
100
110
|
onNavigateCommunity?: () => void;
|
|
111
|
+
/**
|
|
112
|
+
* Navigate to a film route from the Rentals tab (watch, resume, rent again).
|
|
113
|
+
*
|
|
114
|
+
* Takes the href rather than being one callback per destination, because a
|
|
115
|
+
* rental row points at a title AND at the episode the fan got to, so the
|
|
116
|
+
* target is data rather than a fixed page. Left out, the row falls back to a
|
|
117
|
+
* full-page visit, which works on any site and is merely slower.
|
|
118
|
+
*/
|
|
119
|
+
onNavigateFilm?: (href: string) => void;
|
|
101
120
|
/**
|
|
102
121
|
* Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
|
|
103
122
|
* (which honors the visitor's selected currency + tenant exchange rates), so the
|
|
@@ -164,6 +183,7 @@ interface TabContext {
|
|
|
164
183
|
currency?: string;
|
|
165
184
|
onNavigateMembership?: () => void;
|
|
166
185
|
onNavigateCommunity?: () => void;
|
|
186
|
+
onNavigateFilm?: (href: string) => void;
|
|
167
187
|
}
|
|
168
188
|
|
|
169
189
|
// ---- page -------------------------------------------------------------------
|
|
@@ -178,6 +198,7 @@ export function AccountDashboard({
|
|
|
178
198
|
onTabChange,
|
|
179
199
|
onNavigateMembership,
|
|
180
200
|
onNavigateCommunity,
|
|
201
|
+
onNavigateFilm,
|
|
181
202
|
formatAmount,
|
|
182
203
|
formatDate,
|
|
183
204
|
currency,
|
|
@@ -188,6 +209,17 @@ export function AccountDashboard({
|
|
|
188
209
|
const { t } = useCardStyles();
|
|
189
210
|
const tr = useForgeT();
|
|
190
211
|
const { data: siteConfig } = useSiteConfig();
|
|
212
|
+
/**
|
|
213
|
+
* Does this fan hold any rental at all? That is the whole gate on the Rentals
|
|
214
|
+
* tab, on the same principle as the community link above: the server's own
|
|
215
|
+
* answer decides, and a surface with nothing behind it is not drawn.
|
|
216
|
+
*
|
|
217
|
+
* Film rentals are off by default and per profile, so on most sites this read
|
|
218
|
+
* comes back empty for ever and the tab never appears. It is the SAME query
|
|
219
|
+
* `FilmRentalList` runs inside the tab (same key, same arguments), so
|
|
220
|
+
* react-query serves both from one request rather than two.
|
|
221
|
+
*/
|
|
222
|
+
const { data: rentals } = useMyFilmRentals(user?.id);
|
|
191
223
|
|
|
192
224
|
const fmtAmount = useAmountFormatter(formatAmount);
|
|
193
225
|
const fmtDate = formatDate ?? defaultFormatDate;
|
|
@@ -200,8 +232,18 @@ export function AccountDashboard({
|
|
|
200
232
|
currency: currency ?? siteConfig?.currency,
|
|
201
233
|
onNavigateMembership,
|
|
202
234
|
onNavigateCommunity,
|
|
235
|
+
onNavigateFilm,
|
|
203
236
|
};
|
|
204
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The strip hides Rentals until there is something in it. The one exception
|
|
240
|
+
* is the tab currently being shown, so a link straight to `?tab=rentals` (a
|
|
241
|
+
* receipt email, a bookmark) does not land on a page whose strip disowns the
|
|
242
|
+
* panel underneath it.
|
|
243
|
+
*/
|
|
244
|
+
const hasRentals = (rentals?.total ?? 0) > 0;
|
|
245
|
+
const visibleTabs = ACCOUNT_TABS.filter((key) => key !== "rentals" || hasRentals || tab === "rentals");
|
|
246
|
+
|
|
205
247
|
if (!user) return <Loading fullPage />;
|
|
206
248
|
|
|
207
249
|
return (
|
|
@@ -222,7 +264,7 @@ export function AccountDashboard({
|
|
|
222
264
|
</div>
|
|
223
265
|
|
|
224
266
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 24 }}>
|
|
225
|
-
{
|
|
267
|
+
{visibleTabs.map((tabKey) => (
|
|
226
268
|
<button
|
|
227
269
|
key={tabKey}
|
|
228
270
|
onClick={() => onTabChange?.(tabKey)}
|
|
@@ -247,6 +289,7 @@ export function AccountDashboard({
|
|
|
247
289
|
{tab === "tickets" && <TicketsTab ctx={ctx} />}
|
|
248
290
|
{tab === "waitlist" && <WaitlistTab ctx={ctx} />}
|
|
249
291
|
{tab === "bookings" && <BookingsTab ctx={ctx} />}
|
|
292
|
+
{tab === "rentals" && <RentalsTab ctx={ctx} />}
|
|
250
293
|
{tab === "saved" && <SavedTab />}
|
|
251
294
|
{tab === "account" && <AccountTab ctx={ctx} />}
|
|
252
295
|
{tab === "notifications" && <NotificationsTab />}
|
|
@@ -1082,15 +1125,6 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
1082
1125
|
);
|
|
1083
1126
|
}
|
|
1084
1127
|
|
|
1085
|
-
/**
|
|
1086
|
-
* The call UI, fetched on the click. `import()` splits it (and the media SDK
|
|
1087
|
-
* with mediasoup underneath) out of the `./ui` bundle, so a visitor who never
|
|
1088
|
-
* joins a call never downloads it. `bookingCallWindow` is deliberately in a
|
|
1089
|
-
* module with no such dependency, which is what lets the CONTROL be decided
|
|
1090
|
-
* here without loading the SDK.
|
|
1091
|
-
*/
|
|
1092
|
-
const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
|
|
1093
|
-
|
|
1094
1128
|
/**
|
|
1095
1129
|
* The buyer's way into a coaching video call, on a code website.
|
|
1096
1130
|
*
|
|
@@ -1101,12 +1135,18 @@ const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
|
|
|
1101
1135
|
* the client joins "from their account on your site", so this is where it has
|
|
1102
1136
|
* to be. It renders the SAME `<CallStage>` the coach sees in admin.
|
|
1103
1137
|
*
|
|
1104
|
-
* ## Why
|
|
1138
|
+
* ## Why this is a LINK and not the call
|
|
1139
|
+
*
|
|
1140
|
+
* It used to mount the room inline, between two rows of a list, at whatever
|
|
1141
|
+
* width the portal happened to be. A call has its own full-screen page now
|
|
1142
|
+
* (`/i/video-calls/:callId`) and it is the SAME address the coach opens from
|
|
1143
|
+
* the dashboard, so both sides of a session meet in the same place instead of
|
|
1144
|
+
* in two different-looking products. Linking also keeps the media SDK out of
|
|
1145
|
+
* this bundle without needing a lazy boundary here: the page that needs
|
|
1146
|
+
* mediasoup is the only thing that loads it.
|
|
1105
1147
|
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
* rooms the server would then have to provision. So it lives behind Join, and
|
|
1109
|
-
* leaving unmounts it.
|
|
1148
|
+
* A plain anchor rather than a router `Link`, because this component ships to
|
|
1149
|
+
* creator sites and must not assume which router mounted it.
|
|
1110
1150
|
*
|
|
1111
1151
|
* ## Why the window ticks
|
|
1112
1152
|
*
|
|
@@ -1119,7 +1159,6 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
|
1119
1159
|
const { button } = useCardStyles();
|
|
1120
1160
|
const tr = useForgeT();
|
|
1121
1161
|
const [now, setNow] = useState(() => new Date());
|
|
1122
|
-
const [joined, setJoined] = useState(false);
|
|
1123
1162
|
|
|
1124
1163
|
useEffect(() => {
|
|
1125
1164
|
const timer = setInterval(() => setNow(new Date()), 30_000);
|
|
@@ -1129,24 +1168,6 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
|
1129
1168
|
const call = bookingCallWindow(booking, now);
|
|
1130
1169
|
if (!call.isVideo) return null;
|
|
1131
1170
|
|
|
1132
|
-
if (joined) {
|
|
1133
|
-
// The provider's lifetime is exactly the call's: unmounting this subtree is
|
|
1134
|
-
// what closes the socket, stops the local tracks and puts the camera light
|
|
1135
|
-
// out. Leaving therefore goes through state, not through a `close()` that
|
|
1136
|
-
// leaves a live provider mounted to reconnect on its next render.
|
|
1137
|
-
return (
|
|
1138
|
-
<div style={{ marginTop: 12 }}>
|
|
1139
|
-
<Suspense fallback={<Loading />}>
|
|
1140
|
-
<LazyBookingCallScreen
|
|
1141
|
-
bookingId={booking.id}
|
|
1142
|
-
title={booking.coachingProductTitle}
|
|
1143
|
-
onLeave={() => setJoined(false)}
|
|
1144
|
-
/>
|
|
1145
|
-
</Suspense>
|
|
1146
|
-
</div>
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
1171
|
if (!call.isOpen) {
|
|
1151
1172
|
// State the buyer has to act on, so it is content and stays visible: the
|
|
1152
1173
|
// room is not open yet, and this is when to come back.
|
|
@@ -1168,11 +1189,25 @@ function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
|
1168
1189
|
);
|
|
1169
1190
|
}
|
|
1170
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Open, but no address: nothing to link to, so draw nothing.
|
|
1194
|
+
*
|
|
1195
|
+
* Deliberately AFTER the window branch, so a session that has not opened yet
|
|
1196
|
+
* still says when it does. That copy is about the SESSION and must not
|
|
1197
|
+
* depend on whether a room record exists.
|
|
1198
|
+
*
|
|
1199
|
+
* This is reachable only for a booking confirmed before calls became
|
|
1200
|
+
* addressable, since a confirmation now writes the record. The row above
|
|
1201
|
+
* still says the session is a video call, so the buyer is not left thinking
|
|
1202
|
+
* it is somewhere else; a dead button would be worse than none.
|
|
1203
|
+
*/
|
|
1204
|
+
if (!booking.videoCallId) return null;
|
|
1205
|
+
|
|
1171
1206
|
return (
|
|
1172
1207
|
<div style={{ marginTop: 12 }}>
|
|
1173
|
-
<
|
|
1208
|
+
<a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
|
|
1174
1209
|
{tr("forge.account_dashboard.booking_join_video")}
|
|
1175
|
-
</
|
|
1210
|
+
</a>
|
|
1176
1211
|
</div>
|
|
1177
1212
|
);
|
|
1178
1213
|
}
|
|
@@ -1294,6 +1329,35 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
|
|
|
1294
1329
|
);
|
|
1295
1330
|
}
|
|
1296
1331
|
|
|
1332
|
+
// ---- Rentals tab ------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Films and series this fan has rented, live and expired.
|
|
1336
|
+
*
|
|
1337
|
+
* `/i/films/library` already answered this, and nothing on the account page led
|
|
1338
|
+
* to it: a fan who rented a film found their tickets, their orders and their
|
|
1339
|
+
* sessions here and no sign that the rental existed. This is that page's list,
|
|
1340
|
+
* rendered in the tab strip where a buyer looks for a purchase.
|
|
1341
|
+
*
|
|
1342
|
+
* The affordance per row comes from the SERVER's `isLive`, not from arithmetic
|
|
1343
|
+
* repeated here: a running window offers Watch (Resume, when there is progress
|
|
1344
|
+
* to resume), and a window that has closed or been refunded offers Rent again.
|
|
1345
|
+
* That decision lives in one place, `FilmRentalList`, and both surfaces read it.
|
|
1346
|
+
*/
|
|
1347
|
+
function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
1348
|
+
const { t, card } = useCardStyles();
|
|
1349
|
+
const tr = useForgeT();
|
|
1350
|
+
|
|
1351
|
+
return (
|
|
1352
|
+
<div style={card}>
|
|
1353
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1354
|
+
{tr("forge.account_dashboard.rentals_title")}
|
|
1355
|
+
</h2>
|
|
1356
|
+
<FilmRentalList navigate={ctx.onNavigateFilm} />
|
|
1357
|
+
</div>
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1297
1361
|
// ---- Orders tab -------------------------------------------------------------
|
|
1298
1362
|
|
|
1299
1363
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|