@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.
Files changed (40) 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/_tests/paymentFlowReturnUrl.spec.tsx +165 -0
  5. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  6. package/src/data/queries/useFilms.ts +561 -0
  7. package/src/data/queries/usePaymentFlow.ts +28 -3
  8. package/src/i18n/de.json +130 -28
  9. package/src/i18n/en.json +130 -28
  10. package/src/index.ts +5 -0
  11. package/src/provider/ForgeAppProvider.tsx +10 -0
  12. package/src/provider/ForgeProvider.tsx +18 -3
  13. package/src/server/index.ts +76 -17
  14. package/src/ui/headless/_tests/dialogPaystackStandDown.spec.tsx +185 -0
  15. package/src/ui/headless/dialog.tsx +139 -4
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +21 -16
  26. package/src/ui/media/CallStage.tsx +59 -1
  27. package/src/ui/media/CallWindowNotice.tsx +94 -0
  28. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  29. package/src/ui/media/index.ts +9 -0
  30. package/src/ui/styled/AccountDashboard.tsx +101 -37
  31. package/src/ui/styled/FilmCatalog.tsx +278 -0
  32. package/src/ui/styled/FilmDetail.tsx +661 -0
  33. package/src/ui/styled/FilmLibrary.tsx +291 -0
  34. package/src/ui/styled/FilmWatch.tsx +701 -0
  35. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  36. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +200 -0
  37. package/src/ui/styled/forge-utilities.css +6 -0
  38. package/src/utils/_tests/paystackCheckout.spec.ts +82 -1
  39. package/src/utils/paystackCheckout.ts +47 -0
  40. 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
+ });
@@ -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 { lazy, Suspense, useEffect, useState } from "react";
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
- {ACCOUNT_TABS.map((tabKey) => (
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 the call is mounted only after a click
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
- * The provider connects on mount and closes on unmount. Rendering it for every
1107
- * row in a list of sessions would open a media connection per row, against
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
- <button type="button" onClick={() => setJoined(true)} style={button}>
1208
+ <a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
1174
1209
  {tr("forge.account_dashboard.booking_join_video")}
1175
- </button>
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 }) {