@tribe-nest/forge 3.31.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 (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  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 +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. package/src/utils/structuredData.ts +88 -17
@@ -1,7 +1,7 @@
1
1
  import { useCallback, type ReactNode } from "react";
2
2
 
3
3
  import { MediaRoomProvider } from "@tribe-nest/media-client/react";
4
- import type { MediaGrants } from "@tribe-nest/media-protocol";
4
+ import { MediaError, type MediaErrorCode, type MediaGrants } from "@tribe-nest/media-protocol";
5
5
 
6
6
  import { useForge } from "../../provider/ForgeProvider";
7
7
 
@@ -96,11 +96,42 @@ export function useBookingSessionCredentials(bookingId: string): () => Promise<B
96
96
  // function on every parent render, and `<MediaRoomProvider>` holds its
97
97
  // options in a ref precisely because that is the default mistake.
98
98
  return useCallback(async () => {
99
- const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
100
- return res.data as BookingSessionCredentials;
99
+ try {
100
+ const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
101
+ return res.data as BookingSessionCredentials;
102
+ } catch (error) {
103
+ throw credentialRefusal(error);
104
+ }
101
105
  }, [client, bookingId]);
102
106
  }
103
107
 
108
+ /**
109
+ * The server's refusal, in the SDK's vocabulary, so the screen shows it.
110
+ *
111
+ * The join endpoint answers a person who belongs in the call with the REAL
112
+ * reason when it says no: the room opens in ten minutes, the session is not
113
+ * confirmed, the window has closed. Left as an axios error, that sentence never
114
+ * reached the screen: the SDK's `causeFromError` reads a non-`MediaError` as a
115
+ * dropped socket ("Request failed with status code 409"), and `callStatus`
116
+ * then says "Connection lost" about a call the person was never in. As a
117
+ * `MediaError` the cause is `refused`, which the screen renders as "Could not
118
+ * join this call" with the server's own sentence and a Try again. That is what
119
+ * every docblock on this surface promised and what the wire could not deliver.
120
+ *
121
+ * Only a response with a status is translated. A network failure (no
122
+ * response) really is a connection problem and keeps its own reading.
123
+ */
124
+ function credentialRefusal(error: unknown): unknown {
125
+ const response = (error as { response?: { status?: number; data?: { message?: unknown } } } | null)?.response;
126
+ const status = response?.status;
127
+ if (typeof status !== "number") return error;
128
+
129
+ const message = typeof response?.data?.message === "string" ? response.data.message : undefined;
130
+ const code: MediaErrorCode =
131
+ status === 401 ? "unauthorized" : status === 404 ? "no_such_room" : status >= 500 ? "internal" : "bad_request";
132
+ return new MediaError(code, message ?? `${status}`, status >= 500);
133
+ }
134
+
104
135
  export type BookingCallProviderProps = {
105
136
  bookingId: string;
106
137
  /** Rendered while the first connection is in flight. */
@@ -136,59 +167,16 @@ export function BookingCallProvider({
136
167
  );
137
168
  }
138
169
 
139
- /** How early the room opens, and how long it stays open. Mirrors the server. */
140
- export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
141
- export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
142
-
143
- const MINUTE = 60_000;
144
-
145
- /** What a booking has to carry for the window to be computable. */
146
- export type BookingCallSubject = {
147
- status: string;
148
- sessionStartTime: string;
149
- sessionEndTime: string;
150
- location?: { type: string } | null;
151
- };
152
-
153
- export type BookingCallWindow = {
154
- /** Does this session happen on a platform video call at all? */
155
- isVideo: boolean;
156
- /** May a ticket be minted right now? Draw the Join control from this. */
157
- isOpen: boolean;
158
- /** Before the window, so the answer is "come back at". */
159
- opensAt: Date;
160
- /** After it the server refuses new tickets. */
161
- closesAt: Date;
162
- };
163
-
164
170
  /**
165
- * Is this session's room open, and when does it open?
166
- *
167
- * For DRAWING the control and nothing else. The server applies the same window
168
- * and its refusal is the decision: a clock that is ten minutes fast must not be
169
- * able to talk itself into a room, and a clock that is ten minutes slow must
170
- * not hide a session that has already started. Show the server's refusal
171
- * verbatim when a join fails rather than pre-judging it here.
172
- *
173
- * `now` is a parameter so a caller can drive it from a ticking value and get a
174
- * re-render at the moment the window opens; a hook reading the clock itself
175
- * would be stale until something else happened to re-render.
171
+ * The window logic lives in `bookingWindow.ts`, a module with NO media
172
+ * dependency, so `AccountDashboard` (in the `./ui` entry every site loads) can
173
+ * decide whether to DRAW a Join control without pulling the SDK into that
174
+ * bundle. Re-exported here so `@tribe-nest/forge/media` keeps its surface.
176
175
  */
177
- export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
178
- const startsAt = new Date(booking.sessionStartTime);
179
- const endsAt = new Date(booking.sessionEndTime);
180
- const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
181
- const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
182
-
183
- // A cancelled session has no room to enter, which is also why the server
184
- // withholds `location` for anything that is not confirmed - so this reads
185
- // `isVideo: false` for one, rather than offering a button that 409s.
186
- const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
187
-
188
- return {
189
- isVideo,
190
- isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
191
- opensAt,
192
- closesAt,
193
- };
194
- }
176
+ export {
177
+ BOOKING_CALL_CLOSES_MINUTES_AFTER,
178
+ BOOKING_CALL_OPENS_MINUTES_BEFORE,
179
+ bookingCallWindow,
180
+ type BookingCallSubject,
181
+ type BookingCallWindow,
182
+ } from "./bookingWindow";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * When a coaching session's video room is open, computed WITHOUT the media SDK.
3
+ *
4
+ * This module deliberately imports nothing from `@tribe-nest/media-client`.
5
+ * `AccountDashboard` lives in the `./ui` entry that every code website loads,
6
+ * and it needs to know whether to draw a Join control long before anybody
7
+ * presses it. Keeping the window pure means that decision costs the site
8
+ * nothing; the call UI itself is loaded on the click (see `BookingCallScreen`).
9
+ * `@tribe-nest/forge/media` re-exports everything here, so a site that already
10
+ * imports from that barrel sees no difference.
11
+ */
12
+
13
+ /**
14
+ * How early the room opens, and how long it stays open.
15
+ *
16
+ * A MIRROR of the server's `SESSION_JOIN_OPENS_MINUTES_BEFORE` and
17
+ * `SESSION_JOIN_CLOSES_MINUTES_AFTER` (`apps/backend/src/services/public/
18
+ * coaching/commands/bookingSession.ts`), and a mirror is the most this side
19
+ * can hold. The server's own `roomOpensAt` / `roomClosesAt` arrive only with a
20
+ * join ticket, and a ticket is only minted INSIDE the window, so the one moment
21
+ * a site needs "opens at" copy (before the window) is the one moment the
22
+ * server's timestamps are unreachable. These constants exist for that pre-join
23
+ * draw and nothing else: the decision is the server's, its refusal names the
24
+ * real window, and `<CallStage>` shows that refusal verbatim. Change the server
25
+ * and this pair together.
26
+ */
27
+ export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
28
+ export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
29
+
30
+ const MINUTE = 60_000;
31
+
32
+ /** What a booking has to carry for the window to be computable. */
33
+ export type BookingCallSubject = {
34
+ status: string;
35
+ sessionStartTime: string;
36
+ sessionEndTime: string;
37
+ location?: { type: string } | null;
38
+ };
39
+
40
+ export type BookingCallWindow = {
41
+ /** Does this session happen on a platform video call at all? */
42
+ isVideo: boolean;
43
+ /** May a ticket be minted right now? Draw the Join control from this. */
44
+ isOpen: boolean;
45
+ /** Before the window, so the answer is "come back at". */
46
+ opensAt: Date;
47
+ /** After it the server refuses new tickets. */
48
+ closesAt: Date;
49
+ };
50
+
51
+ /**
52
+ * Is this session's room open, and when does it open?
53
+ *
54
+ * For DRAWING the control and nothing else. The server applies the same window
55
+ * and its refusal is the decision: a clock that is ten minutes fast must not be
56
+ * able to talk itself into a room, and a clock that is ten minutes slow must
57
+ * not hide a session that has already started. Show the server's refusal
58
+ * verbatim when a join fails rather than pre-judging it here.
59
+ *
60
+ * `now` is a parameter so a caller can drive it from a ticking value and get a
61
+ * re-render at the moment the window opens; a hook reading the clock itself
62
+ * would be stale until something else happened to re-render.
63
+ */
64
+ export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
65
+ const startsAt = new Date(booking.sessionStartTime);
66
+ const endsAt = new Date(booking.sessionEndTime);
67
+ const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
68
+ const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
69
+
70
+ // A cancelled session has no room to enter, which is also why the server
71
+ // withholds `location` for anything that is not confirmed - so this reads
72
+ // `isVideo: false` for one, rather than offering a button that 409s.
73
+ const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
74
+
75
+ return {
76
+ isVideo,
77
+ isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
78
+ opensAt,
79
+ closesAt,
80
+ };
81
+ }
@@ -177,13 +177,27 @@ export function remoteAudioProducerIds(input: {
177
177
 
178
178
  export type CallStatusTone = "connecting" | "live" | "reconnecting" | "ended" | "failed";
179
179
 
180
+ /**
181
+ * The connection, as KEYS into Forge's bundles.
182
+ *
183
+ * `callStatus` is pure and has no locale, so it names copy rather than
184
+ * carrying it: `CallStage` puts the keys through `useForgeT()`, and a site
185
+ * laying out its own status line does the same. Returning English sentences
186
+ * from here was how the whole call screen shipped in one language on sites
187
+ * whose every other word was German.
188
+ *
189
+ * `detailText` is the exception, on purpose: it is the SERVER'S own sentence
190
+ * (a refusal message, a close reason) and is shown verbatim, never translated
191
+ * into a friendlier lie.
192
+ */
180
193
  export type CallStatus = {
181
194
  tone: CallStatusTone;
182
- /** Always shown. Connection state is CONTENT, never a help hint. */
183
- headline: string;
184
- /** The server's own words when it has any. Shown verbatim rather than
185
- * translated into a friendlier lie. */
186
- detail?: string;
195
+ /** Always shown. Connection state is CONTENT, never a help hint. `forge.call_stage.*`. */
196
+ headlineKey: string;
197
+ /** Our own second line, when there is one. `forge.call_stage.*`. */
198
+ detailKey?: string;
199
+ /** The server's own words when it has any. Shown verbatim. */
200
+ detailText?: string;
187
201
  /** Is pressing something the right response? Only when waiting will not fix it. */
188
202
  canRetry: boolean;
189
203
  };
@@ -205,6 +219,9 @@ export type CallStatusInput = {
205
219
  recovering?: boolean;
206
220
  };
207
221
 
222
+ const ENDED = "forge.call_stage.status_ended";
223
+ const LEFT = "forge.call_stage.status_left";
224
+
208
225
  /**
209
226
  * The room's states, told honestly.
210
227
  *
@@ -235,23 +252,25 @@ export function callStatus(input: CallStatusInput): CallStatus {
235
252
  if (input.phase === "closed") {
236
253
  return {
237
254
  tone: "ended",
238
- headline: "The call has ended",
239
- ...(input.closedReason ? { detail: input.closedReason } : {}),
255
+ headlineKey: ENDED,
256
+ ...(input.closedReason ? { detailText: input.closedReason } : {}),
240
257
  canRetry: false,
241
258
  };
242
259
  }
243
260
 
244
- if (connectionState === "connected") return { tone: "live", headline: "Connected", canRetry: false };
261
+ if (connectionState === "connected") {
262
+ return { tone: "live", headlineKey: "forge.call_stage.status_connected", canRetry: false };
263
+ }
245
264
 
246
265
  if (connectionState === "idle" || connectionState === "connecting") {
247
- return { tone: "connecting", headline: "Connecting to the call", canRetry: false };
266
+ return { tone: "connecting", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
248
267
  }
249
268
 
250
269
  if (connectionState === "closed") {
251
270
  if (error?.type === "room_closed") {
252
- return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
271
+ return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
253
272
  }
254
- return { tone: "ended", headline: "You have left the call", canRetry: false };
273
+ return { tone: "ended", headlineKey: LEFT, canRetry: false };
255
274
  }
256
275
 
257
276
  // Everything below is `reconnecting`, where the cause is the whole story.
@@ -259,12 +278,12 @@ export function callStatus(input: CallStatusInput): CallStatus {
259
278
  case "refused":
260
279
  return {
261
280
  tone: "failed",
262
- headline: "Could not join this call",
263
- detail: error.message ?? error.code,
281
+ headlineKey: "forge.call_stage.status_refused",
282
+ detailText: error.message ?? error.code,
264
283
  canRetry: true,
265
284
  };
266
285
  case "room_closed":
267
- return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
286
+ return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
268
287
  case "draining":
269
288
  // A drain is the node asking to be left, so the move is the SDK's job and
270
289
  // a button would only get in its way - as long as the move is actually
@@ -274,30 +293,30 @@ export function callStatus(input: CallStatusInput): CallStatus {
274
293
  return recovering
275
294
  ? {
276
295
  tone: "reconnecting",
277
- headline: "Moving you to another server",
278
- detail: "This takes a few seconds and nobody else is dropped.",
296
+ headlineKey: "forge.call_stage.status_moving",
297
+ detailKey: "forge.call_stage.status_moving_detail",
279
298
  canRetry: false,
280
299
  }
281
300
  : {
282
301
  tone: "failed",
283
- headline: "Could not move you to another server",
284
- detail: "Nobody else was dropped. Try again to rejoin.",
302
+ headlineKey: "forge.call_stage.status_move_failed",
303
+ detailKey: "forge.call_stage.status_move_failed_detail",
285
304
  canRetry: true,
286
305
  };
287
306
  case "closed_by_client":
288
- return { tone: "ended", headline: "You have left the call", canRetry: false };
307
+ return { tone: "ended", headlineKey: LEFT, canRetry: false };
289
308
  default:
290
309
  return recovering
291
310
  ? {
292
311
  tone: "reconnecting",
293
- headline: "Connection lost, reconnecting",
294
- detail: "Everyone else is still in the call.",
312
+ headlineKey: "forge.call_stage.status_reconnecting",
313
+ detailKey: "forge.call_stage.status_reconnecting_detail",
295
314
  canRetry: true,
296
315
  }
297
316
  : {
298
317
  tone: "failed",
299
- headline: "Connection lost",
300
- detail: "Everyone else is still in the call. Try again to rejoin.",
318
+ headlineKey: "forge.call_stage.status_lost",
319
+ detailKey: "forge.call_stage.status_lost_detail",
301
320
  canRetry: true,
302
321
  };
303
322
  }
@@ -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,
@@ -36,6 +36,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
36
36
  import { useSiteConfig } from "../../data/queries/useWebsite";
37
37
  import { useAmountFormatter } from "../format/useFormatCurrency";
38
38
  import { Loading } from "./Loading";
39
+ import { bookingCallWindow } from "../media/bookingWindow";
39
40
  import { formatCountdown } from "./EventWaitlist";
40
41
  import { AddToCalendar } from "./AddToCalendar";
41
42
  import { WalletPassButtons } from "./WalletPassButtons";
@@ -930,13 +931,13 @@ function BookingLocationLine({
930
931
  }
931
932
 
932
933
  /**
933
- * A call on the platform's own media network. A LINE, never the call itself.
934
+ * A call on the platform's own media network. A LINE, and the way in is the
935
+ * `BookingCallEntry` control the row renders below it.
934
936
  *
935
937
  * There is no URL to render and there never will be: entry is a ticket minted
936
- * per attempt and valid for minutes. Mounting the room here is also the wrong
937
- * place mechanically - the call UI lives behind `@tribe-nest/forge/media`,
938
- * whose media-client dependency is an OPTIONAL peer, and importing it from
939
- * this entry point would break every site that has not installed it.
938
+ * per attempt and valid for minutes, so the Join control mints one on the
939
+ * click and mounts the call in place (`BookingCallEntry`, loaded lazily so a
940
+ * site pays for the SDK only when somebody presses it).
940
941
  */
941
942
  if (location.type === "video") {
942
943
  return (
@@ -1073,6 +1074,97 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1073
1074
  </div>
1074
1075
 
1075
1076
  {picking && <RescheduleSlotPicker booking={booking} onDone={() => setPicking(false)} />}
1077
+
1078
+ {/* The way INTO a video session: a Join control while the room is open,
1079
+ the call itself once pressed. Nothing for any other kind of session. */}
1080
+ {!cancelled && <BookingCallEntry booking={booking} />}
1081
+ </div>
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * The buyer's way into a coaching video call, on a code website.
1087
+ *
1088
+ * The room, the hooks, the credential endpoint and `BookingCallProvider` all
1089
+ * shipped before this control existed, and none of it was reachable from the
1090
+ * one place a client looks: the row for their session printed "Video call on
1091
+ * this site" and offered nothing to press. Admin's own help text told coaches
1092
+ * the client joins "from their account on your site", so this is where it has
1093
+ * to be. It renders the SAME `<CallStage>` the coach sees in admin.
1094
+ *
1095
+ * ## Why this is a LINK and not the call
1096
+ *
1097
+ * It used to mount the room inline, between two rows of a list, at whatever
1098
+ * width the portal happened to be. A call has its own full-screen page now
1099
+ * (`/i/video-calls/:callId`) and it is the SAME address the coach opens from
1100
+ * the dashboard, so both sides of a session meet in the same place instead of
1101
+ * in two different-looking products. Linking also keeps the media SDK out of
1102
+ * this bundle without needing a lazy boundary here: the page that needs
1103
+ * mediasoup is the only thing that loads it.
1104
+ *
1105
+ * A plain anchor rather than a router `Link`, because this component ships to
1106
+ * creator sites and must not assume which router mounted it.
1107
+ *
1108
+ * ## Why the window ticks
1109
+ *
1110
+ * `bookingCallWindow` takes `now` as an argument rather than reading the clock,
1111
+ * so a portal left open across the opening minute has to be told. A minute is
1112
+ * fine: the server applies the same window and refuses a ticket outside it,
1113
+ * and its refusal is what `<CallStage>` shows rather than a pre-judgement here.
1114
+ */
1115
+ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1116
+ const { button } = useCardStyles();
1117
+ const tr = useForgeT();
1118
+ const [now, setNow] = useState(() => new Date());
1119
+
1120
+ useEffect(() => {
1121
+ const timer = setInterval(() => setNow(new Date()), 30_000);
1122
+ return () => clearInterval(timer);
1123
+ }, []);
1124
+
1125
+ const call = bookingCallWindow(booking, now);
1126
+ if (!call.isVideo) return null;
1127
+
1128
+ if (!call.isOpen) {
1129
+ // State the buyer has to act on, so it is content and stays visible: the
1130
+ // room is not open yet, and this is when to come back.
1131
+ return (
1132
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1133
+ {now < call.opensAt
1134
+ ? tr("forge.account_dashboard.booking_video_opens_at", {
1135
+ datetime: call.opensAt.toLocaleString(undefined, {
1136
+ weekday: "short",
1137
+ day: "numeric",
1138
+ month: "short",
1139
+ hour: "numeric",
1140
+ minute: "2-digit",
1141
+ timeZone: booking.coachingProductTimezone ?? undefined,
1142
+ }),
1143
+ })
1144
+ : tr("forge.account_dashboard.booking_video_closed")}
1145
+ </div>
1146
+ );
1147
+ }
1148
+
1149
+ /**
1150
+ * Open, but no address: nothing to link to, so draw nothing.
1151
+ *
1152
+ * Deliberately AFTER the window branch, so a session that has not opened yet
1153
+ * still says when it does. That copy is about the SESSION and must not
1154
+ * depend on whether a room record exists.
1155
+ *
1156
+ * This is reachable only for a booking confirmed before calls became
1157
+ * addressable, since a confirmation now writes the record. The row above
1158
+ * still says the session is a video call, so the buyer is not left thinking
1159
+ * it is somewhere else; a dead button would be worse than none.
1160
+ */
1161
+ if (!booking.videoCallId) return null;
1162
+
1163
+ return (
1164
+ <div style={{ marginTop: 12 }}>
1165
+ <a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
1166
+ {tr("forge.account_dashboard.booking_join_video")}
1167
+ </a>
1076
1168
  </div>
1077
1169
  );
1078
1170
  }