@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
@@ -23,8 +23,10 @@ import {
23
23
  useRemoteTrack,
24
24
  useRoomState,
25
25
  useVisibleProducers,
26
+ type LocalSource,
26
27
  } from "@tribe-nest/media-client/react";
27
28
 
29
+ import { useForgeT, type ForgeT } from "../../i18n";
28
30
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
29
31
  import { CallHelpHint } from "./CallHelpHint";
30
32
  import {
@@ -57,6 +59,17 @@ import {
57
59
  * screen that looks like somebody else's product on every site that installs
58
60
  * it.
59
61
  *
62
+ * ## Where the words come from
63
+ *
64
+ * `useForgeT()`, like every other Forge component, with keys under
65
+ * `forge.call_stage.*`. `callStatus` returns KEYS for the same reason it is
66
+ * pure: it has no locale. The one thing rendered verbatim is the server's own
67
+ * sentence (a refusal, a close reason), because a friendlier translation of a
68
+ * refusal is a lie about why you were refused. Admin renders this same
69
+ * component inside `<ForgeI18nProvider>` with the dashboard's locale; outside
70
+ * any provider the hook answers in English, so a site that has not mounted one
71
+ * still gets a working call.
72
+ *
60
73
  * ## What it does NOT decide
61
74
  *
62
75
  * Whether a control may be used. `useGrants()` says what the node minted this
@@ -85,6 +98,7 @@ export type CallStageProps = {
85
98
 
86
99
  export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
87
100
  const tokens = useThemeTokens();
101
+ const t = useForgeT();
88
102
  const { connectionState, error, recovering, retry } = useMediaRoom();
89
103
  const state = useRoomState();
90
104
  const peers = useParticipants();
@@ -130,23 +144,46 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
130
144
  backgroundColor: tokens.background,
131
145
  color: tokens.text,
132
146
  fontFamily: tokens.fontFamily,
147
+ /**
148
+ * Fill the surface rather than growing to fit the tiles.
149
+ *
150
+ * This laid out as a content-sized panel, which was right when a call
151
+ * was a modal and wrong the moment it became a full-screen page. With
152
+ * ONE participant `auto-fit` collapses the grid to a single full-width
153
+ * column, and a 16/9 tile at full width is taller than the viewport, so
154
+ * the controls were pushed off the bottom of the screen. Two people
155
+ * halved the width and hid the bug, which is why it only appeared to
156
+ * somebody sitting in a call alone.
157
+ *
158
+ * `100%` degrades to `auto` inside a parent with no height, so the
159
+ * embedded use (`BookingCallScreen`) is unaffected.
160
+ */
161
+ height: "100%",
162
+ minHeight: 0,
163
+ boxSizing: "border-box",
164
+ overflow: "hidden",
133
165
  }}
134
166
  >
135
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", flexWrap: "wrap" }}>
167
+ <div
168
+ style={{
169
+ display: "flex",
170
+ alignItems: "center",
171
+ justifyContent: "space-between",
172
+ gap: "0.75rem",
173
+ flexWrap: "wrap",
174
+ }}
175
+ >
136
176
  {title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
137
177
  <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
138
- {recording && <RecordingIndicator tokens={tokens} />}
139
- <ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
178
+ {recording && <RecordingIndicator tokens={tokens} t={t} />}
179
+ <ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
140
180
  </div>
141
181
  </div>
142
182
 
143
183
  <div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
144
- <span>
145
- In this call: {callHeadCount(tiles)}
146
- </span>
147
- <CallHelpHint label="Why can I hear someone but not see them?">
148
- A tile with a name and no picture means that person has their camera off, or the call is busy enough that only
149
- the people currently speaking are sent in full. Both are normal, and their audio is unaffected.
184
+ <span>{t("forge.call_stage.head_count", { count: callHeadCount(tiles) })}</span>
185
+ <CallHelpHint label={t("forge.call_stage.help_hear_not_see_label")}>
186
+ {t("forge.call_stage.help_hear_not_see")}
150
187
  </CallHelpHint>
151
188
  </div>
152
189
 
@@ -155,11 +192,34 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
155
192
  display: "grid",
156
193
  gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
157
194
  gap: "0.75rem",
195
+ // The only part that may grow, and the only part that may scroll.
196
+ // `minHeight: 0` is what actually lets it shrink: a flex item defaults
197
+ // to `min-height: auto`, so without it the grid refuses to go below
198
+ // its content and pushes the controls out of the box instead.
199
+ flex: 1,
200
+ minHeight: 0,
201
+ overflowY: "auto",
202
+ /**
203
+ * Centred in the free space, not stacked at the top.
204
+ *
205
+ * `safe` is the load-bearing word: plain `center` centres the
206
+ * OVERFLOW too, so once there are more tiles than fit, the first row
207
+ * is pushed above the scroll origin and cannot be reached. `safe
208
+ * center` centres while there is room and falls back to start the
209
+ * moment there is not. An engine that does not understand it drops
210
+ * the declaration and starts at the top, which is the same fallback.
211
+ */
212
+ alignContent: "safe center",
213
+ // Horizontally centred too. A tile clamped by `maxHeight` shrinks its
214
+ // width to keep 16/9, so it no longer fills its track and would
215
+ // otherwise sit against the left edge. With two or more tiles each
216
+ // fills its track and this changes nothing.
217
+ justifyItems: "center",
158
218
  }}
159
219
  >
160
- <LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
220
+ <LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
161
221
  {tiles.map((tile) => (
162
- <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
222
+ <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
163
223
  ))}
164
224
  </div>
165
225
 
@@ -167,7 +227,7 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
167
227
  <RemoteAudio key={producerId} producerId={producerId} />
168
228
  ))}
169
229
 
170
- <CallControls tokens={tokens} onLeave={onLeave} />
230
+ <CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
171
231
  </div>
172
232
  );
173
233
  }
@@ -182,14 +242,18 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
182
242
  function ConnectionStatus({
183
243
  status,
184
244
  tokens,
245
+ t,
185
246
  onRetry,
186
247
  }: {
187
248
  status: CallStatus;
188
249
  tokens: ResolvedThemeTokens;
250
+ t: ForgeT;
189
251
  onRetry: () => void;
190
252
  }) {
191
253
  const busy = status.tone === "connecting" || status.tone === "reconnecting";
192
254
  const alarming = status.tone === "failed";
255
+ // Our own copy is translated; the server's sentence is shown as it came.
256
+ const detail = status.detailKey ? t(status.detailKey) : status.detailText;
193
257
 
194
258
  return (
195
259
  <div
@@ -211,8 +275,8 @@ function ConnectionStatus({
211
275
  style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
212
276
  />
213
277
  )}
214
- <span style={{ fontWeight: alarming ? 600 : 400 }}>{status.headline}</span>
215
- {status.detail && <span style={{ color: tokens.muted }}>{status.detail}</span>}
278
+ <span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
279
+ {detail && <span style={{ color: tokens.muted }}>{detail}</span>}
216
280
  {status.canRetry && (
217
281
  <button
218
282
  type="button"
@@ -232,7 +296,7 @@ function ConnectionStatus({
232
296
  }}
233
297
  >
234
298
  <RefreshCw width={12} height={12} aria-hidden="true" />
235
- Try again
299
+ {t("forge.call_stage.retry")}
236
300
  </button>
237
301
  )}
238
302
  </div>
@@ -240,7 +304,7 @@ function ConnectionStatus({
240
304
  }
241
305
 
242
306
  /** Somebody is recording. A consent signal, so it is content and always shown. */
243
- function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
307
+ function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
244
308
  return (
245
309
  <span
246
310
  style={{
@@ -259,7 +323,7 @@ function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
259
323
  aria-hidden="true"
260
324
  style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
261
325
  />
262
- This call is being recorded
326
+ {t("forge.call_stage.recording")}
263
327
  </span>
264
328
  );
265
329
  }
@@ -268,6 +332,10 @@ const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: b
268
332
  position: "relative",
269
333
  overflow: "hidden",
270
334
  aspectRatio: String(aspectRatio),
335
+ // A lone tile spans the whole grid, and 16/9 of a wide surface is taller than
336
+ // the surface. Clamping crops rather than distorts, because the video inside
337
+ // is `object-fit: cover`.
338
+ maxHeight: "100%",
271
339
  borderRadius: tokens.cornerRadius,
272
340
  backgroundColor: tokens.surface,
273
341
  // The active speaker is shown by the FRAME rather than by re-ordering the
@@ -306,10 +374,12 @@ function ParticipantTile({
306
374
  tile,
307
375
  tokens,
308
376
  aspectRatio,
377
+ t,
309
378
  }: {
310
379
  tile: CallTile;
311
380
  tokens: ResolvedThemeTokens;
312
381
  aspectRatio: number;
382
+ t: ForgeT;
313
383
  }) {
314
384
  // `useRemoteTrack` takes a producer id unconditionally so the hook order is
315
385
  // the same for a tile with video and a tile without. An id that matches
@@ -327,21 +397,23 @@ function ParticipantTile({
327
397
  data-speaking={tile.isSpeaking ? "true" : "false"}
328
398
  >
329
399
  {showVideo ? (
330
- <video
331
- ref={attach}
332
- autoPlay
333
- playsInline
334
- muted
335
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
336
- />
400
+ <video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
337
401
  ) : (
338
- <span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text, padding: "0 0.75rem", textAlign: "center" }}>
402
+ <span
403
+ style={{
404
+ fontSize: "0.9375rem",
405
+ fontWeight: 600,
406
+ color: tokens.text,
407
+ padding: "0 0.75rem",
408
+ textAlign: "center",
409
+ }}
410
+ >
339
411
  {tile.name}
340
412
  </span>
341
413
  )}
342
414
  <span style={tileName(tokens)}>
343
415
  {tile.name}
344
- {!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
416
+ {!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
345
417
  </span>
346
418
  </div>
347
419
  );
@@ -361,13 +433,15 @@ function RemoteAudio({ producerId }: { producerId: string }) {
361
433
  }
362
434
 
363
435
  /** This viewer, as they are being sent. Muted, or it is a feedback loop. */
364
- function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
436
+ function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
365
437
  const state = useRoomState();
366
438
  const publications = useLocalPublications();
367
439
  const videoRef = useRef<HTMLVideoElement | null>(null);
368
440
 
369
441
  const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
370
- const track = camera?.track;
442
+ // A paused camera is still captured and still a publication, but nothing is
443
+ // being sent, so the preview shows the name the others are seeing.
444
+ const track = camera && !camera.paused ? camera.track : undefined;
371
445
 
372
446
  useEffect(() => {
373
447
  const element = videoRef.current;
@@ -381,7 +455,7 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
381
455
  };
382
456
  }, [track]);
383
457
 
384
- const name = state.identity ? "You" : "You (not connected yet)";
458
+ const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
385
459
 
386
460
  return (
387
461
  <div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
@@ -401,6 +475,13 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
401
475
  );
402
476
  }
403
477
 
478
+ /** One sentence per source a drop took away, keyed by the SDK's word for it. */
479
+ const LOST_SOURCE_KEYS: Record<LocalSource, string> = {
480
+ microphone: "forge.call_stage.lost_microphone",
481
+ camera: "forge.call_stage.lost_camera",
482
+ screen: "forge.call_stage.lost_screen",
483
+ };
484
+
404
485
  const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
405
486
  display: "inline-flex",
406
487
  alignItems: "center",
@@ -423,31 +504,66 @@ const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = fals
423
504
  * a listen-only seat is not offered a microphone it would be refused. That is a
424
505
  * rendering decision; the node refuses the publish itself either way.
425
506
  *
507
+ * ## Mute is a pause, not a republish
508
+ *
509
+ * The first press on Unmute or Start camera captures and publishes. Every press
510
+ * after that PAUSES or RESUMES the publication the room already holds, through
511
+ * `setPaused`. A toggle built as unpublish-then-publish starts every unmute
512
+ * with `getUserMedia`, and Safari puts a permission prompt in front of every
513
+ * call to it, so a person on an iPad was asked whether the site may use the
514
+ * microphone each time they spoke. Pausing keeps the capture open (the
515
+ * browser's device indicator stays on, which the camera hint says) and the way
516
+ * back asks nobody anything. Screen share is the exception: stopping it ends
517
+ * the capture, because sharing again has to go through the picker anyway.
518
+ *
426
519
  * ## Why every toggle is disabled while it is working
427
520
  *
428
521
  * Each of these is a read-then-write: it reads whether the source is live and
429
- * publishes or unpublishes accordingly. The flag it reads does not move until
430
- * the publish has resolved, and a publish starts with a permission prompt that
431
- * can sit on screen for seconds - so a button that looks untouched is a button
432
- * a person presses again, and two presses used to mean two producers from one
433
- * identity and everyone else hearing them doubled. `useLocalMedia` holds the
434
- * real lock (a synchronous ref, since both clicks land before React re-renders
435
- * anything); this is the part that says so on screen, because a control that
436
- * silently ignores you is only marginally better than one that misbehaves.
522
+ * publishes, pauses or resumes accordingly. The flag it reads does not move
523
+ * until the publish has resolved, and a publish starts with a permission
524
+ * prompt that can sit on screen for seconds - so a button that looks untouched
525
+ * is a button a person presses again, and two presses used to mean two
526
+ * producers from one identity and everyone else hearing them doubled.
527
+ * `useLocalMedia` holds the real lock (a synchronous ref, since both clicks
528
+ * land before React re-renders anything); this is the part that says so on
529
+ * screen, because a control that silently ignores you is only marginally
530
+ * better than one that misbehaves.
437
531
  */
438
- function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeave?: () => void }) {
532
+ function CallControls({
533
+ tokens,
534
+ t,
535
+ connected,
536
+ onLeave,
537
+ }: {
538
+ tokens: ResolvedThemeTokens;
539
+ t: ForgeT;
540
+ connected: boolean;
541
+ onLeave?: () => void;
542
+ }) {
439
543
  const grants = useGrants();
440
544
  const { room } = useMediaRoom();
441
545
  const media = useLocalMedia();
442
546
 
547
+ // What the person is actually SENDING, which is what the button says.
548
+ const microphoneLive = media.isMicrophoneEnabled && !media.paused.microphone;
549
+ const cameraLive = media.isCameraEnabled && !media.paused.camera;
550
+
443
551
  // Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
444
552
  // fresh object every render, so memoising on it would rebuild the callback
445
553
  // every render anyway while looking as though it did not.
446
554
  const toggleMicrophone = () => {
447
- void (media.isMicrophoneEnabled ? media.unpublish("microphone") : media.publishMicrophone());
555
+ if (!media.isMicrophoneEnabled) {
556
+ void media.publishMicrophone();
557
+ return;
558
+ }
559
+ void media.setPaused("microphone", microphoneLive);
448
560
  };
449
561
  const toggleCamera = () => {
450
- void (media.isCameraEnabled ? media.unpublish("camera") : media.publishCamera());
562
+ if (!media.isCameraEnabled) {
563
+ void media.publishCamera();
564
+ return;
565
+ }
566
+ void media.setPaused("camera", cameraLive);
451
567
  };
452
568
  const toggleScreen = () => {
453
569
  void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
@@ -462,39 +578,57 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
462
578
  }, [room, onLeave]);
463
579
 
464
580
  return (
465
- <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.5rem" }}>
581
+ <div
582
+ style={{
583
+ display: "flex",
584
+ flexWrap: "wrap",
585
+ alignItems: "center",
586
+ gap: "0.5rem",
587
+ // Pinned to the bottom of the surface. The tile grid already claims the
588
+ // free space with `flex: 1`, so this holds even if that ever changes:
589
+ // the controls are the one thing that must never move off screen,
590
+ // because Leave is here.
591
+ marginTop: "auto",
592
+ flexShrink: 0,
593
+ }}
594
+ >
466
595
  {canPublishSource(grants, "microphone") && (
467
596
  <button
468
597
  type="button"
469
598
  onClick={toggleMicrophone}
470
599
  disabled={media.pending.microphone}
471
600
  aria-busy={media.pending.microphone}
472
- style={controlButton(tokens, media.isMicrophoneEnabled, media.pending.microphone)}
601
+ style={controlButton(tokens, microphoneLive, media.pending.microphone)}
473
602
  >
474
- {media.isMicrophoneEnabled ? (
603
+ {microphoneLive ? (
475
604
  <Mic width={16} height={16} aria-hidden="true" />
476
605
  ) : (
477
606
  <MicOff width={16} height={16} aria-hidden="true" />
478
607
  )}
479
- {media.isMicrophoneEnabled ? "Mute" : "Unmute"}
608
+ {microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
480
609
  </button>
481
610
  )}
482
611
 
483
612
  {canPublishSource(grants, "camera") && (
484
- <button
485
- type="button"
486
- onClick={toggleCamera}
487
- disabled={media.pending.camera}
488
- aria-busy={media.pending.camera}
489
- style={controlButton(tokens, media.isCameraEnabled, media.pending.camera)}
490
- >
491
- {media.isCameraEnabled ? (
492
- <Video width={16} height={16} aria-hidden="true" />
493
- ) : (
494
- <VideoOff width={16} height={16} aria-hidden="true" />
495
- )}
496
- {media.isCameraEnabled ? "Stop camera" : "Start camera"}
497
- </button>
613
+ <span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
614
+ <button
615
+ type="button"
616
+ onClick={toggleCamera}
617
+ disabled={media.pending.camera}
618
+ aria-busy={media.pending.camera}
619
+ style={controlButton(tokens, cameraLive, media.pending.camera)}
620
+ >
621
+ {cameraLive ? (
622
+ <Video width={16} height={16} aria-hidden="true" />
623
+ ) : (
624
+ <VideoOff width={16} height={16} aria-hidden="true" />
625
+ )}
626
+ {cameraLive ? t("forge.call_stage.camera_stop") : t("forge.call_stage.camera_start")}
627
+ </button>
628
+ <CallHelpHint label={t("forge.call_stage.help_camera_stop_label")}>
629
+ {t("forge.call_stage.help_camera_stop")}
630
+ </CallHelpHint>
631
+ </span>
498
632
  )}
499
633
 
500
634
  {canPublishSource(grants, "screen") && (
@@ -511,11 +645,10 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
511
645
  ) : (
512
646
  <MonitorUp width={16} height={16} aria-hidden="true" />
513
647
  )}
514
- {media.screenSharing ? "Stop sharing" : "Share screen"}
648
+ {media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
515
649
  </button>
516
- <CallHelpHint label="What does sharing my screen send?">
517
- Whatever you pick in the browser prompt, live, to everyone in the call. Your camera keeps running alongside
518
- it rather than being replaced, so people can still see you while you present.
650
+ <CallHelpHint label={t("forge.call_stage.help_screen_label")}>
651
+ {t("forge.call_stage.help_screen")}
519
652
  </CallHelpHint>
520
653
  </span>
521
654
  )}
@@ -526,7 +659,7 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
526
659
  style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
527
660
  >
528
661
  <PhoneOff width={16} height={16} aria-hidden="true" />
529
- Leave
662
+ {t("forge.call_stage.leave")}
530
663
  </button>
531
664
 
532
665
  {/* A denied permission prompt is something the person has to be told
@@ -537,6 +670,22 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
537
670
  {media.error.message}
538
671
  </span>
539
672
  )}
673
+
674
+ {/* State the person has to act on, so it is content and stays visible: a
675
+ drop stopped their capture (the light went out, on purpose), the room
676
+ is back, and nothing will turn the microphone on again but them. Shown
677
+ once the room is back rather than during the drop, when the status
678
+ line above is already saying "reconnecting". An alert rather than a
679
+ status: somebody who misses it is talking to nobody. */}
680
+ {connected && media.lostSources.length > 0 && (
681
+ <span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
682
+ {media.lostSources.map((source) => (
683
+ <span key={source} style={{ display: "block" }}>
684
+ {t(LOST_SOURCE_KEYS[source])}
685
+ </span>
686
+ ))}
687
+ </span>
688
+ )}
540
689
  </div>
541
690
  );
542
691
  }
@@ -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
+ }