@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
@@ -5,8 +5,10 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { MediaRoomProvider, type MediaRoomProviderProps } from "@tribe-nest/media-client/react";
7
7
  import type { MediaDevice, MediaTransport } from "@tribe-nest/media-client";
8
+ import { MediaError } from "@tribe-nest/media-protocol";
8
9
 
9
10
  import { CallStage } from "../CallStage";
11
+ import { ForgeI18nProvider } from "../../../i18n";
10
12
  import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
11
13
  import { FakeNode, settle } from "./fakeNode";
12
14
 
@@ -67,7 +69,14 @@ const fakeTransport = (): MediaTransport => ({
67
69
  id: "t-1",
68
70
  async produce(input) {
69
71
  produced.push(String((input.appData as { source?: string } | undefined)?.source));
70
- return { id: `p-local-${produced.length}`, kind: "audio", closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
72
+ return {
73
+ id: `p-local-${produced.length}`,
74
+ kind: "audio",
75
+ closed: false,
76
+ pause: vi.fn(),
77
+ resume: vi.fn(),
78
+ close: vi.fn(),
79
+ };
71
80
  },
72
81
  async consume(input) {
73
82
  return {
@@ -105,27 +114,36 @@ const FAST_RECOVERY: Recovery = { baseMs: 1, maxMs: 4, factor: 1, jitter: "none"
105
114
  const mount = (
106
115
  node: FakeNode,
107
116
  children: ReactNode,
108
- options: { autoSubscribe?: boolean; reconnect?: Recovery } = {},
117
+ options: { autoSubscribe?: boolean; reconnect?: Recovery; locale?: string } = {},
109
118
  ) =>
110
119
  render(
111
- <ForgeThemeProvider theme={theme}>
112
- <MediaRoomProvider
113
- getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
114
- device={fakeDevice}
115
- webSocket={node.factory}
116
- autoSubscribe={options.autoSubscribe ?? false}
117
- reconnect={options.reconnect ?? NO_RECOVERY}
118
- >
119
- {children}
120
- </MediaRoomProvider>
121
- </ForgeThemeProvider>,
120
+ <ForgeI18nProvider locale={options.locale ?? "en"}>
121
+ <ForgeThemeProvider theme={theme}>
122
+ <MediaRoomProvider
123
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
124
+ device={fakeDevice}
125
+ webSocket={node.factory}
126
+ autoSubscribe={options.autoSubscribe ?? false}
127
+ reconnect={options.reconnect ?? NO_RECOVERY}
128
+ >
129
+ {children}
130
+ </MediaRoomProvider>
131
+ </ForgeThemeProvider>
132
+ </ForgeI18nProvider>,
122
133
  );
123
134
 
124
- /** A permission prompt and a capture, as the browser would give them. */
125
- const stubCapture = () => {
126
- const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack & {
127
- stop: ReturnType<typeof vi.fn>;
128
- };
135
+ /**
136
+ * A permission prompt and a capture, as the browser would give them.
137
+ *
138
+ * The track is an `EventTarget`, as a real `MediaStreamTrack` is, so a spec can
139
+ * end it the way the browser's own "Stop sharing" bar does.
140
+ */
141
+ const stubCapture = (kind: "audio" | "video" = "audio") => {
142
+ const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn>; enabled: boolean };
143
+ target.kind = kind;
144
+ target.stop = vi.fn();
145
+ target.enabled = true;
146
+ const track = target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
129
147
  const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
130
148
  Object.defineProperty(navigator, "mediaDevices", {
131
149
  configurable: true,
@@ -381,7 +399,8 @@ describe("the grid", () => {
381
399
 
382
400
  const { container, getByTestId } = mount(node, <CallStage />);
383
401
  await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
384
- const order = () => [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
402
+ const order = () =>
403
+ [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
385
404
  const before = order();
386
405
 
387
406
  node.event({ event: "activeSpeakers", producerIds: ["p-ben-audio"] });
@@ -466,6 +485,47 @@ describe("the connection state, said honestly", () => {
466
485
  expect(getByText("Try again")).toBeTruthy();
467
486
  });
468
487
 
488
+ /**
489
+ * The ticket endpoint said no BEFORE any socket existed. This used to wedge
490
+ * the room on "Connecting" for ever (the SDK never ran its close path for a
491
+ * failure with no socket), and the docblocks promising the server's refusal
492
+ * would be shown verbatim could not be kept. Now it is a refusal like any
493
+ * other: the sentence, and a control.
494
+ */
495
+ it("shows the credential endpoint's refusal verbatim, with a retry, rather than a spinner", async () => {
496
+ const node = new FakeNode({});
497
+ let attempts = 0;
498
+ const { getByRole, getByText } = render(
499
+ <ForgeThemeProvider theme={theme}>
500
+ <MediaRoomProvider
501
+ getCredentials={() => {
502
+ attempts += 1;
503
+ if (attempts === 1) {
504
+ throw new MediaError("bad_request", "The room opens 15 minutes before the session.");
505
+ }
506
+ return { mediaUrl: "wss://media.example", token: "t" };
507
+ }}
508
+ device={fakeDevice}
509
+ webSocket={node.factory}
510
+ autoSubscribe={false}
511
+ reconnect={NO_RECOVERY}
512
+ >
513
+ <CallStage />
514
+ </MediaRoomProvider>
515
+ </ForgeThemeProvider>,
516
+ );
517
+
518
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
519
+ expect(getByRole("status").textContent).toContain("The room opens 15 minutes before the session.");
520
+ expect(getByRole("status").textContent).not.toContain("Connection lost");
521
+
522
+ // And Try again is not decoration: the next attempt fetches a ticket again
523
+ // and gets in.
524
+ fireEvent.click(getByText("Try again"));
525
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
526
+ expect(attempts).toBe(2);
527
+ });
528
+
469
529
  it("says the call ended when the node closes the room", async () => {
470
530
  const node = new FakeNode({});
471
531
  const { getByRole } = mount(node, <CallStage />);
@@ -683,3 +743,189 @@ describe("the things that must stay visible", () => {
683
743
  await waitFor(() => expect(queryByText(/means that person has their camera off/)).toBeTruthy());
684
744
  });
685
745
  });
746
+
747
+ /**
748
+ * The screen speaks the site's language.
749
+ *
750
+ * Every string on the call screen was English, written into the component,
751
+ * against Forge's own convention: a German site rendered a German account page
752
+ * with an English call in the middle of it, and `callStatus` returned sentences
753
+ * a translator could not reach. This pins that the SAME component, under the
754
+ * SAME provider every Forge component reads, is German when the site is.
755
+ */
756
+ describe("the screen is translated", () => {
757
+ it("renders the status and the controls in the site's language", async () => {
758
+ const node = new FakeNode({});
759
+ const { getByRole, getByText, queryByText } = mount(node, <CallStage />, { locale: "de" });
760
+
761
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
762
+ expect(getByText("Stummschaltung aufheben")).toBeTruthy();
763
+ expect(getByText("Kamera starten")).toBeTruthy();
764
+ expect(getByText("Verlassen")).toBeTruthy();
765
+ expect(getByText(/In diesem Anruf:/).textContent).toBe("In diesem Anruf: 1");
766
+ expect(queryByText("Unmute")).toBeNull();
767
+ });
768
+
769
+ it("keeps the server's own sentence verbatim inside a translated status", async () => {
770
+ const node = new FakeNode({});
771
+ const { getByRole } = mount(node, <CallStage />, { locale: "de" });
772
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
773
+
774
+ node.event({ event: "roomClosed", reason: "the host ended it" });
775
+
776
+ // The headline is ours and is German; the reason is the server's and is not
777
+ // put through a translator, because a friendlier version of a refusal is a
778
+ // lie about why.
779
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Der Anruf ist beendet"));
780
+ expect(getByRole("status").textContent).toContain("the host ended it");
781
+ });
782
+
783
+ it("falls back to English outside any i18n provider, so a site without one still gets a call", async () => {
784
+ const node = new FakeNode({});
785
+ const { getByRole } = render(
786
+ <ForgeThemeProvider theme={theme}>
787
+ <MediaRoomProvider
788
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
789
+ device={fakeDevice}
790
+ webSocket={node.factory}
791
+ autoSubscribe={false}
792
+ reconnect={NO_RECOVERY}
793
+ >
794
+ <CallStage />
795
+ </MediaRoomProvider>
796
+ </ForgeThemeProvider>,
797
+ );
798
+
799
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
800
+ });
801
+ });
802
+
803
+ /**
804
+ * Mute is a pause, not a second permission prompt.
805
+ *
806
+ * The toggles were unpublish-then-publish, and every publish starts with
807
+ * `getUserMedia`. Safari asks permission on EVERY call to it, so a person on an
808
+ * iPad was asked whether the site may use the microphone each time they
809
+ * unmuted. Now the first press captures and every press after that pauses or
810
+ * resumes the publication the room already holds.
811
+ */
812
+ describe("mute and camera-off keep the capture", () => {
813
+ it("mutes and unmutes with ONE capture, and tells the node each time", async () => {
814
+ const { getUserMedia } = stubCapture();
815
+ const node = new FakeNode({});
816
+ const { getByText } = mount(node, <CallStage />);
817
+
818
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
819
+ fireEvent.click(getByText("Unmute"));
820
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
821
+
822
+ fireEvent.click(getByText("Mute"));
823
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
824
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
825
+ // Paused, not closed: the microphone is still published, just silent.
826
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(0);
827
+
828
+ fireEvent.click(getByText("Unmute"));
829
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
830
+ expect(node.received.filter((f) => f.method === "resumeProducer")).toHaveLength(1);
831
+ // The whole point: one prompt for the whole call.
832
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
833
+ expect(produced).toEqual(["microphone"]);
834
+ });
835
+
836
+ it("shows the name in the local preview while the camera is paused, and explains the light behind a ?", async () => {
837
+ stubCapture("video");
838
+ const node = new FakeNode({});
839
+ const { getByText, getByTestId, getByLabelText, queryByText } = mount(node, <CallStage />);
840
+
841
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
842
+ fireEvent.click(getByText("Start camera"));
843
+ await waitFor(() => expect(getByText("Stop camera")).toBeTruthy());
844
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeTruthy());
845
+
846
+ fireEvent.click(getByText("Stop camera"));
847
+
848
+ // What the others see is the name, so that is what the preview shows.
849
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
850
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeNull());
851
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
852
+ // The camera stays open (the light can stay on), and that is an explanation,
853
+ // so it lives behind a ? with a name that says what it explains.
854
+ expect(queryByText(/keeps the camera open/)).toBeNull();
855
+ fireEvent.click(getByLabelText("What does Stop camera do?"));
856
+ await waitFor(() => expect(queryByText(/keeps the camera open/)).toBeTruthy());
857
+ });
858
+ });
859
+
860
+ /**
861
+ * The browser can end a share without asking us.
862
+ *
863
+ * Chrome and Firefox draw their own "Stop sharing" bar over a screen share.
864
+ * Nothing about it went near `unpublish`, so the node kept a producer nobody
865
+ * was feeding, everybody else kept a frozen tile, and this button read "Stop
866
+ * sharing" about a share that had ended.
867
+ */
868
+ describe("a screen share stopped from the browser's own bar", () => {
869
+ it("is unpublished, and the control follows", async () => {
870
+ const { track } = stubCapture("video");
871
+ const node = new FakeNode({});
872
+ const { getByText } = mount(node, <CallStage />);
873
+
874
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
875
+ fireEvent.click(getByText("Share screen"));
876
+ await waitFor(() => expect(getByText("Stop sharing")).toBeTruthy());
877
+
878
+ track.dispatchEvent(new Event("ended"));
879
+
880
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
881
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(1);
882
+ });
883
+ });
884
+
885
+ /**
886
+ * A recovered connection is not a recovered microphone, and the screen says so.
887
+ *
888
+ * The drop stops every local capture (right: a light left on over a dead
889
+ * session is retained capture) and the SDK does not turn it back on by itself
890
+ * (also right: capture is a permission, and it is the person's press). What
891
+ * was wrong is that nothing then told them. The room came back, the status
892
+ * read "Connected", and a coach who had been talking for ten minutes was
893
+ * talking to nobody until the client asked whether they were still there.
894
+ */
895
+ describe("what a drop took away is said, once the room is back", () => {
896
+ it("names the microphone, and stops once it is back on", async () => {
897
+ stubCapture();
898
+ const node = new FakeNode({});
899
+ const { getByText, getByRole, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
900
+
901
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
902
+ fireEvent.click(getByText("Unmute"));
903
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
904
+
905
+ node.socket.drop();
906
+
907
+ await waitFor(() => expect(node.sockets.length).toBe(2));
908
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
909
+ // Content, not a hint: this is the thing they have to act on.
910
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeTruthy());
911
+ expect(getByText("Unmute")).toBeTruthy();
912
+
913
+ fireEvent.click(getByText("Unmute"));
914
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
915
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeNull());
916
+ });
917
+
918
+ it("says nothing after a deliberate leave", async () => {
919
+ stubCapture();
920
+ const node = new FakeNode({});
921
+ const { getByText, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
922
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
923
+ fireEvent.click(getByText("Unmute"));
924
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
925
+
926
+ fireEvent.click(getByText("Leave"));
927
+
928
+ await settle(30);
929
+ expect(queryByText(/Your microphone was turned off/)).toBeNull();
930
+ });
931
+ });
@@ -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
+ });
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { renderHook } from "@testing-library/react";
4
+ import { MediaError } from "@tribe-nest/media-protocol";
4
5
 
5
6
  import {
6
7
  BOOKING_CALL_CLOSES_MINUTES_AFTER,
@@ -167,6 +168,53 @@ describe("useBookingSessionCredentials", () => {
167
168
  expect(credentials.role).toBe("guest");
168
169
  });
169
170
 
171
+ /**
172
+ * The server's refusal must reach the screen as the server's SENTENCE. Left
173
+ * as an axios error, the SDK read it as a dropped socket and the screen said
174
+ * "Connection lost" about a call the person was never in.
175
+ */
176
+ it("turns the server's refusal into a MediaError carrying its message", async () => {
177
+ post.mockRejectedValueOnce({
178
+ response: { status: 409, data: { message: "The room opens 15 minutes before the session." } },
179
+ });
180
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
181
+
182
+ const error: unknown = await getCredentials().then(
183
+ () => undefined,
184
+ (e: unknown) => e,
185
+ );
186
+
187
+ expect(error).toBeInstanceOf(MediaError);
188
+ expect((error as MediaError).message).toBe("The room opens 15 minutes before the session.");
189
+ expect((error as MediaError).code).toBe("bad_request");
190
+ expect((error as MediaError).retryable).toBe(false);
191
+ });
192
+
193
+ it("maps a 401 and a 404 to the SDK's own codes, and a 5xx to a retryable internal", async () => {
194
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
195
+ const refusal = async (status: number) => {
196
+ post.mockRejectedValueOnce({ response: { status, data: { message: `status ${status}` } } });
197
+ return getCredentials().then(
198
+ () => undefined,
199
+ (e: unknown) => e as MediaError,
200
+ );
201
+ };
202
+
203
+ expect((await refusal(401))?.code).toBe("unauthorized");
204
+ expect((await refusal(404))?.code).toBe("no_such_room");
205
+ const internal = await refusal(503);
206
+ expect(internal?.code).toBe("internal");
207
+ expect(internal?.retryable).toBe(true);
208
+ });
209
+
210
+ it("leaves a network failure alone, because that one really is a connection problem", async () => {
211
+ const network = new Error("Network Error");
212
+ post.mockRejectedValueOnce(network);
213
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
214
+
215
+ await expect(getCredentials()).rejects.toBe(network);
216
+ });
217
+
170
218
  it("keeps ONE callback identity per booking", () => {
171
219
  const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
172
220
  const first = result.current;
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import type { ProducerEntry } from "@tribe-nest/media-client";
4
4
  import type { Peer } from "@tribe-nest/media-protocol";
5
5
 
6
+ import { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
6
7
  import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
7
8
 
8
9
  /**
@@ -22,12 +23,12 @@ const peer = (identity: string, name?: string): Peer => ({
22
23
  /** The recording process, which joins the room as a real peer. */
23
24
  const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
24
25
 
25
- const producer = (
26
- producerId: string,
27
- identity: string,
28
- kind: "audio" | "video",
29
- paused = false,
30
- ): ProducerEntry => ({ producerId, identity, kind, paused });
26
+ const producer = (producerId: string, identity: string, kind: "audio" | "video", paused = false): ProducerEntry => ({
27
+ producerId,
28
+ identity,
29
+ kind,
30
+ paused,
31
+ });
31
32
 
32
33
  describe("callTiles keeps the grid from collapsing", () => {
33
34
  it("draws a tile for somebody with NO video at all", () => {
@@ -256,21 +257,67 @@ describe("callStatus is honest about what is happening", () => {
256
257
  // other is worth pressing a button about.
257
258
  expect(dropped.tone).toBe("reconnecting");
258
259
  expect(refused.tone).toBe("failed");
259
- expect(dropped.headline).not.toBe(refused.headline);
260
+ expect(dropped.headlineKey).not.toBe(refused.headlineKey);
260
261
  });
261
262
 
262
263
  it("shows the server's own words for a refusal rather than a friendly lie", () => {
263
- expect(callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "room_closed" } }).detail).toBe(
264
- "room_closed",
265
- );
264
+ // As TEXT, not as a key: a refusal is the one thing on the screen that must
265
+ // never be put through a translator, because the server's sentence is the
266
+ // reason and a friendlier version of it is a lie about why.
267
+ const refusedByCode = callStatus({
268
+ connectionState: "reconnecting",
269
+ error: { type: "refused", code: "room_closed" },
270
+ });
271
+ expect(refusedByCode.detailText).toBe("room_closed");
272
+ expect(refusedByCode.detailKey).toBeUndefined();
266
273
  expect(
267
274
  callStatus({
268
275
  connectionState: "reconnecting",
269
276
  error: { type: "refused", code: "unauthorized", message: "this session has ended" },
270
- }).detail,
277
+ }).detailText,
271
278
  ).toBe("this session has ended");
272
279
  });
273
280
 
281
+ /**
282
+ * Keys, not sentences. `callStatus` has no locale, and every English string
283
+ * it used to return was rendered as-is on sites whose every other word was
284
+ * German. What it names has to exist in every bundle, or a German visitor
285
+ * reads a raw dotted key at the one moment they are being told the call is
286
+ * over.
287
+ */
288
+ it("names copy by key, and every key it can produce exists in every bundle", () => {
289
+ const seen = new Set<string>();
290
+ const inputs: Parameters<typeof callStatus>[0][] = [
291
+ { connectionState: "idle", error: undefined },
292
+ { connectionState: "connecting", error: undefined },
293
+ { connectionState: "connected", error: undefined },
294
+ { connectionState: "connected", error: undefined, phase: "closed" },
295
+ { connectionState: "closed", error: { type: "closed_by_client" } },
296
+ { connectionState: "closed", error: { type: "room_closed", reason: "x" } },
297
+ { connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } },
298
+ { connectionState: "reconnecting", error: { type: "room_closed", reason: "x" } },
299
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 }, recovering: true },
300
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 } },
301
+ { connectionState: "reconnecting", error: { type: "closed_by_client" } },
302
+ { connectionState: "reconnecting", error: { type: "socket_closed" }, recovering: true },
303
+ { connectionState: "reconnecting", error: { type: "socket_closed" } },
304
+ ];
305
+ for (const input of inputs) {
306
+ const status = callStatus(input);
307
+ seen.add(status.headlineKey);
308
+ if (status.detailKey) seen.add(status.detailKey);
309
+ }
310
+ for (const locale of FORGE_LOCALES) {
311
+ const bundle = getForgeMessages(locale);
312
+ const missing = [...seen].filter((key) => !key.startsWith("forge.call_stage.") || bundle[key] === undefined);
313
+ expect(missing, `${locale} is missing: ${missing.join(", ")}`).toEqual([]);
314
+ }
315
+ // And translated English reads as the sentences the screen specs assert on.
316
+ expect(translateForge("en", callStatus({ connectionState: "connected", error: undefined }).headlineKey)).toBe(
317
+ "Connected",
318
+ );
319
+ });
320
+
274
321
  it("does not offer a retry for something retrying cannot fix", () => {
275
322
  expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
276
323
  expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
@@ -319,7 +366,7 @@ describe("callStatus is honest about what is happening", () => {
319
366
  // ever opened.
320
367
  expect(stranded.tone).toBe("failed");
321
368
  expect(stranded.canRetry).toBe(true);
322
- expect(stranded.headline).not.toBe(moving.headline);
369
+ expect(stranded.headlineKey).not.toBe(moving.headlineKey);
323
370
  });
324
371
 
325
372
  it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
@@ -330,8 +377,8 @@ describe("callStatus is honest about what is happening", () => {
330
377
  });
331
378
  const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
332
379
 
333
- expect(retrying.headline).toBe("Connection lost, reconnecting");
334
- expect(givenUp.headline).toBe("Connection lost");
380
+ expect(translateForge("en", retrying.headlineKey)).toBe("Connection lost, reconnecting");
381
+ expect(translateForge("en", givenUp.headlineKey)).toBe("Connection lost");
335
382
  expect(givenUp.tone).toBe("failed");
336
383
  expect(givenUp.canRetry).toBe(true);
337
384
  });
@@ -359,7 +406,7 @@ describe("callStatus is honest about what is happening", () => {
359
406
  for (const connectionState of ["closed", "reconnecting"] as const) {
360
407
  const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
361
408
  expect(status.tone).toBe("ended");
362
- expect(status.detail).toBe("the host ended it");
409
+ expect(status.detailText).toBe("the host ended it");
363
410
  }
364
411
  });
365
412
 
@@ -376,7 +423,7 @@ describe("callStatus is honest about what is happening", () => {
376
423
  });
377
424
 
378
425
  expect(status.tone).toBe("ended");
379
- expect(status.detail).toBe("the host ended it");
426
+ expect(status.detailText).toBe("the host ended it");
380
427
  });
381
428
 
382
429
  it("is live only when the room says connected", () => {