@tribe-nest/forge 3.54.0 → 3.58.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 (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -0,0 +1,212 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
+
6
+ import { MediaRoom, type MediaDevice, type MediaTransport } from "@tribe-nest/media-client";
7
+ import { MediaRoomProvider } from "@tribe-nest/media-client/react";
8
+
9
+ import { ForgeI18nProvider } from "../../../i18n";
10
+ import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
11
+ import type { CallStageBreakouts } from "../callBreakouts";
12
+ import { CallStage } from "../CallStage";
13
+ import { FakeNode, settle } from "./fakeNode";
14
+
15
+ /**
16
+ * Breakout rooms on the STAGE: the controls bar and the centre notice.
17
+ *
18
+ * `CallBreakoutsPanel.spec.tsx` covers the rail against a fake adapter; this
19
+ * file covers what the orchestrator adds around it, driven through a real
20
+ * `<MediaRoomProvider>` the way `CallStage.spec.tsx` is, because the two
21
+ * decisions worth pinning here (nothing drawn without the prop, and "waiting"
22
+ * rather than "everyone left" while rooms are open) live in the join between
23
+ * the room's state and the stage.
24
+ */
25
+
26
+ afterEach(() => {
27
+ cleanup();
28
+ delete (navigator as { mediaDevices?: unknown }).mediaDevices;
29
+ });
30
+
31
+ beforeAll(() => {
32
+ if (!("MediaStream" in globalThis)) {
33
+ (globalThis as { MediaStream?: unknown }).MediaStream = class {
34
+ constructor(readonly tracks: unknown[] = []) {}
35
+ };
36
+ }
37
+ });
38
+
39
+ const fakeTransport = (): MediaTransport => ({
40
+ id: "t-1",
41
+ async produce() {
42
+ return { id: "p-local", kind: "audio", closed: false, replaceTrack: vi.fn(async () => undefined), pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
43
+ },
44
+ async consume(input) {
45
+ return {
46
+ id: `c-${input.producerId}`,
47
+ producerId: input.producerId,
48
+ kind: input.kind,
49
+ track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
50
+ pause: vi.fn(),
51
+ resume: vi.fn(),
52
+ close: vi.fn(),
53
+ };
54
+ },
55
+ close: vi.fn(),
56
+ });
57
+
58
+ const fakeDevice = (): MediaDevice => ({
59
+ async load() {},
60
+ loaded: true,
61
+ rtpCapabilities: { codecs: [] } as never,
62
+ canProduce: () => true,
63
+ createSendTransport: () => fakeTransport(),
64
+ createRecvTransport: () => fakeTransport(),
65
+ });
66
+
67
+ const theme: ForgeTheme = { colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" }, cornerRadius: 10 };
68
+
69
+ const mount = (node: FakeNode, children: ReactNode) =>
70
+ render(
71
+ <ForgeI18nProvider locale="en">
72
+ <ForgeThemeProvider theme={theme}>
73
+ <MediaRoomProvider
74
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
75
+ device={fakeDevice}
76
+ webSocket={node.factory}
77
+ autoSubscribe={false}
78
+ reconnect={{ baseMs: 1, maxMs: 4, factor: 1, jitter: "none" }}
79
+ >
80
+ {children}
81
+ </MediaRoomProvider>
82
+ </ForgeThemeProvider>
83
+ </ForgeI18nProvider>,
84
+ );
85
+
86
+ const adapter = (over: Partial<CallStageBreakouts> = {}): CallStageBreakouts => ({
87
+ ready: true,
88
+ canManage: false,
89
+ active: false,
90
+ current: null,
91
+ main: { participantCount: 0, participants: [] },
92
+ rooms: [{ publicId: "room-1", name: "Room 1", position: 1, participantCount: 0, participants: [], assigned: [] }],
93
+ pending: false,
94
+ create: vi.fn(async () => undefined),
95
+ assign: vi.fn(async () => undefined),
96
+ moveSelf: vi.fn(async () => undefined),
97
+ closeAll: vi.fn(async () => undefined),
98
+ refresh: vi.fn(),
99
+ ...over,
100
+ });
101
+
102
+ /**
103
+ * Layer B of the breakout contract ships `MediaRoom.moveTo()` and the
104
+ * `moveRequested` reducer case in `@tribe-nest/media-client`. The cases that
105
+ * need the SDK's half are skipped, not failed, until that build is in the
106
+ * tree; they light up on their own the moment it is.
107
+ */
108
+ const sdkMoves = typeof (MediaRoom.prototype as unknown as { moveTo?: unknown }).moveTo === "function";
109
+
110
+ describe("without the prop, the stage is the stage it was", () => {
111
+ it("draws no Rooms button, no Return to main, and no rooms tab", async () => {
112
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
113
+ const { getByTestId, queryByTestId, queryByText } = mount(node, <CallStage />);
114
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
115
+
116
+ expect(queryByTestId("toggle-rooms")).toBeNull();
117
+ expect(queryByTestId("return-to-main")).toBeNull();
118
+ fireEvent.click(getByTestId("toggle-people"));
119
+ await waitFor(() => expect(getByTestId("call-side-panel")).toBeTruthy());
120
+ expect(queryByText("Rooms")).toBeNull();
121
+ });
122
+
123
+ it("still says everyone left when the room empties", async () => {
124
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
125
+ const { getByTestId } = mount(node, <CallStage />);
126
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
127
+ node.event({ event: "peerLeft", identity: "anna" });
128
+ await waitFor(() => expect(getByTestId("stage-notice-everyone_left")).toBeTruthy());
129
+ });
130
+ });
131
+
132
+ describe("the controls bar", () => {
133
+ it("offers Return to main only to somebody in a breakout room, and it writes the assignment first", async () => {
134
+ const breakouts = adapter({ active: true, current: { publicId: "room-1", name: "Room 1" } });
135
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
136
+ const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
137
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
138
+
139
+ fireEvent.click(getByTestId("return-to-main"));
140
+ // The assignment is written BEFORE the reconnect: the next ticket is
141
+ // minted for whatever the assignment says, so this order is the move.
142
+ await waitFor(() => expect(breakouts.moveSelf).toHaveBeenCalledWith(null));
143
+ });
144
+
145
+ it("offers no Return to main in the main room, and a Rooms button once rooms exist", async () => {
146
+ const breakouts = adapter({ active: true, current: null });
147
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
148
+ const { getByTestId, queryByTestId } = mount(node, <CallStage breakouts={breakouts} />);
149
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
150
+
151
+ expect(queryByTestId("return-to-main")).toBeNull();
152
+ fireEvent.click(getByTestId("toggle-rooms"));
153
+ await waitFor(() => expect(getByTestId("call-breakouts-panel")).toBeTruthy());
154
+ expect(getByTestId("breakouts-where").textContent).toBe("You are in the main room");
155
+ // An attendee's list is read when the tab opens; the adapter does not poll for them.
156
+ expect(breakouts.refresh).toHaveBeenCalled();
157
+ });
158
+
159
+ it("gives the host the Rooms button before any room exists, and the create form behind it", async () => {
160
+ const breakouts = adapter({ canManage: true, active: false, rooms: [] });
161
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
162
+ const { getByTestId } = mount(node, <CallStage breakouts={breakouts} isHost />);
163
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
164
+
165
+ fireEvent.click(getByTestId("toggle-rooms"));
166
+ await waitFor(() => expect(getByTestId("breakouts-create")).toBeTruthy());
167
+ });
168
+ });
169
+
170
+ describe("the centre of the stage while rooms are open", () => {
171
+ it("says WAITING, not everyone left, when the host finds an empty room", async () => {
172
+ const breakouts = adapter({ canManage: true, active: true, current: { publicId: "room-1", name: "Room 1" } });
173
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
174
+ const { getByTestId, queryByTestId } = mount(node, <CallStage breakouts={breakouts} isHost />);
175
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
176
+
177
+ node.event({ event: "peerLeft", identity: "anna" });
178
+ await waitFor(() => expect(getByTestId("stage-notice-waiting")).toBeTruthy());
179
+ expect(queryByTestId("stage-notice-everyone_left")).toBeNull();
180
+ });
181
+
182
+ it.skipIf(!sdkMoves)("says where you are being moved when the node asks you to change rooms", async () => {
183
+ const breakouts = adapter({ active: true });
184
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
185
+ const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
186
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
187
+
188
+ node.event({ event: "moveRequested", room: "booking-1~b1", name: "Room 1" });
189
+
190
+ const notice = await waitFor(() => getByTestId("stage-notice-moving"));
191
+ expect(notice.textContent).toContain("Moving you to Room 1");
192
+ // The SDK leaves and reconnects with a fresh ticket: a SECOND socket.
193
+ await waitFor(() => expect(node.sockets.length).toBe(2));
194
+ await settle(30);
195
+ // Back in a room, the assignment view is re-read.
196
+ await waitFor(() => expect(breakouts.refresh).toHaveBeenCalled());
197
+ });
198
+
199
+ it.skipIf(!sdkMoves)("moves you itself after Return to main, and names the destination while it does", async () => {
200
+ const breakouts = adapter({ active: true, current: { publicId: "room-1", name: "Room 1" } });
201
+ const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
202
+ const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
203
+ await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
204
+
205
+ fireEvent.click(getByTestId("return-to-main"));
206
+
207
+ const notice = await waitFor(() => getByTestId("stage-notice-moving"));
208
+ expect(notice.textContent).toContain("Returning you to the main room");
209
+ await waitFor(() => expect(node.sockets.length).toBe(2));
210
+ await waitFor(() => expect(breakouts.refresh).toHaveBeenCalled());
211
+ });
212
+ });
@@ -4,7 +4,15 @@ import type { ProducerEntry } from "@tribe-nest/media-client";
4
4
  import type { Peer } from "@tribe-nest/media-protocol";
5
5
 
6
6
  import { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
7
- import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds, stageNotice } from "../callState";
7
+ import {
8
+ breakoutMoveHeadline,
9
+ callHeadCount,
10
+ callStatus,
11
+ callTiles,
12
+ canPublishSource,
13
+ remoteAudioProducerIds,
14
+ stageNotice,
15
+ } from "../callState";
8
16
 
9
17
  /**
10
18
  * The two decisions a call screen gets wrong.
@@ -298,6 +306,14 @@ describe("callStatus is honest about what is happening", () => {
298
306
  { connectionState: "reconnecting", error: { type: "room_closed", reason: "x" } },
299
307
  { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 }, recovering: true },
300
308
  { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 } },
309
+ // A breakout move rides the draining cause; both of its headlines must exist too.
310
+ {
311
+ connectionState: "reconnecting",
312
+ error: { type: "draining", reconnectAfterMs: 0 },
313
+ recovering: true,
314
+ moving: { room: "r~b2", name: "Room 2" },
315
+ },
316
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, recovering: true, moving: { room: "r", name: null } },
301
317
  { connectionState: "reconnecting", error: { type: "closed_by_client" } },
302
318
  { connectionState: "reconnecting", error: { type: "socket_closed" }, recovering: true },
303
319
  { connectionState: "reconnecting", error: { type: "socket_closed" } },
@@ -548,6 +564,92 @@ describe("stageNotice: the ONE decision for the centre of the stage", () => {
548
564
  }),
549
565
  ).toBeNull();
550
566
  });
567
+
568
+ /**
569
+ * Breakout rooms.
570
+ *
571
+ * A move between rooms is a leave and a reconnect, and for the second it
572
+ * takes the stage is empty. Read without knowing about the move, that is
573
+ * "everyone else has left the call" over a call nobody left, at exactly the
574
+ * moment the person is being sent somewhere.
575
+ */
576
+ describe("with breakout rooms", () => {
577
+ const reconnecting = status({ tone: "reconnecting", headlineKey: "forge.call_stage.status_moving" });
578
+
579
+ it("says where you are going, and that wins over the empty-stage notices", () => {
580
+ const toRoom = stageNotice({ status: reconnecting, remoteCount: 0, everSawPeers: true, moving: { room: "r~b2", name: "Room 2" } });
581
+ expect(toRoom).toMatchObject({
582
+ kind: "moving",
583
+ headlineKey: "forge.call_stage.breakouts.status_moving",
584
+ headlineVars: { room: "Room 2" },
585
+ canRetry: false,
586
+ });
587
+ expect(translateForge("en", toRoom!.headlineKey, toRoom!.headlineVars)).toBe("Moving you to Room 2");
588
+
589
+ // The same move while still connected (the frame landed, the leave has
590
+ // not finished) and while alone: the move is the truth in both.
591
+ expect(stageNotice({ status: status(), remoteCount: 3, everSawPeers: true, moving: { room: "r~b2", name: "Room 2" } })?.kind).toBe("moving");
592
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: false, moving: { room: "r~b2", name: "Room 2" } })?.kind).toBe("moving");
593
+ });
594
+
595
+ it("says you are coming back when the move has no room name", () => {
596
+ const home = stageNotice({ status: reconnecting, remoteCount: 0, everSawPeers: true, moving: { room: "r", name: null } });
597
+ expect(home).toMatchObject({ kind: "moving", headlineKey: "forge.call_stage.breakouts.status_returning" });
598
+ expect(home!.headlineVars).toBeUndefined();
599
+ expect(translateForge("en", home!.headlineKey)).toBe("Returning you to the main room");
600
+ });
601
+
602
+ it("does NOT let a move hide a terminal state", () => {
603
+ // A move that failed (the rejoin was refused) is the thing to act on; a
604
+ // spinner saying "Moving you to Room 2" over it is the promise nobody
605
+ // is keeping.
606
+ const failed = stageNotice({
607
+ status: status({ tone: "failed", headlineKey: "forge.call_stage.status_move_failed", canRetry: true }),
608
+ remoteCount: 0,
609
+ everSawPeers: true,
610
+ moving: { room: "r~b2", name: "Room 2" },
611
+ });
612
+ expect(failed).toMatchObject({ kind: "failed", canRetry: true });
613
+ const ended = stageNotice({
614
+ status: status({ tone: "ended", headlineKey: "forge.call_stage.status_ended" }),
615
+ remoteCount: 0,
616
+ everSawPeers: true,
617
+ moving: { room: "r~b2", name: "Room 2" },
618
+ });
619
+ expect(ended?.kind).toBe("ended");
620
+ });
621
+
622
+ it("reads an empty stage as WAITING while rooms are open, never as everyone having left", () => {
623
+ // The host hopped into Room 3 before anyone arrived: the people they left
624
+ // in the main room are still on the call.
625
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true, breakoutsActive: true })?.kind).toBe("waiting");
626
+ // And without open rooms the old answer stands.
627
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true, breakoutsActive: false })?.kind).toBe("everyone_left");
628
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true })?.kind).toBe("everyone_left");
629
+ // People on stage: nothing to say, rooms or not.
630
+ expect(stageNotice({ status: status(), remoteCount: 1, everSawPeers: true, breakoutsActive: true })).toBeNull();
631
+ });
632
+
633
+ it("gives the status pill the same destination as the centre", () => {
634
+ // The SDK reports a breakout move with the draining cause a server drain
635
+ // uses, so without `moving` the pill says "Moving you to another server"
636
+ // about a move to Room 2.
637
+ const drain = callStatus({ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, recovering: true });
638
+ const move = callStatus({
639
+ connectionState: "reconnecting",
640
+ error: { type: "draining", reconnectAfterMs: 0 },
641
+ recovering: true,
642
+ moving: { room: "r~b2", name: "Room 2" },
643
+ });
644
+ expect(drain.headlineKey).toBe("forge.call_stage.status_moving");
645
+ expect(move).toMatchObject({ tone: "reconnecting", canRetry: false, ...breakoutMoveHeadline({ room: "r~b2", name: "Room 2" }) });
646
+ expect(translateForge("de", move.headlineKey, move.headlineVars)).toBe("Du wirst in Room 2 verschoben");
647
+ // A move whose rejoin nothing is retrying is a failure like any other drain's.
648
+ expect(
649
+ callStatus({ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, moving: { room: "r", name: null } }).tone,
650
+ ).toBe("failed");
651
+ });
652
+ });
551
653
  });
552
654
 
553
655
  describe("tiles carry their source, and a share never displaces the face", () => {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The breakout-rooms adapter the stage renders, injected like `chat`.
3
+ *
4
+ * A PROP rather than something the stage fetches, for the same reason the chat
5
+ * adapter is one: the three surfaces authenticate differently and the admin
6
+ * dashboard mounts no `ForgeProvider`, so nothing inside the stage may touch
7
+ * an HTTP client. The page composes its lane's hook into THIS shape;
8
+ * `useCallBreakouts` (public lane) is the reference implementation and admin
9
+ * wires `useAdminCallBreakouts` against the same shape.
10
+ *
11
+ * ## What a breakout is, from the stage's side
12
+ *
13
+ * A child room under the call's main room. The join address never changes:
14
+ * the SDK keeps minting on the parent's public id and the backend answers with
15
+ * a token for whichever room this seat is assigned to. So a move is nothing
16
+ * more than "write the assignment, then leave and reconnect", which is what
17
+ * `moveSelf` followed by `room.moveTo()` does, and why `moveSelf` resolves
18
+ * once the ASSIGNMENT is written rather than once the person has arrived.
19
+ *
20
+ * Nothing here decides who may do what: `canManage` is a rendering hint and
21
+ * the server resolves the seat again on every call. A non-host asking for a
22
+ * host action gets the same 404 every other refusal on the call address gives.
23
+ */
24
+ export type BreakoutParticipant = { identity: string; name: string | null };
25
+
26
+ export type BreakoutRoomSummary = {
27
+ publicId: string;
28
+ name: string;
29
+ position: number;
30
+ /** Live, from the plane snapshot. Host only; attendees always read 0. */
31
+ participantCount: number;
32
+ /** Live. Host only; attendees always read an empty list. */
33
+ participants: readonly BreakoutParticipant[];
34
+ /** From the assignment table: who is MEANT to be here, arrived or not. */
35
+ assigned: readonly BreakoutParticipant[];
36
+ };
37
+
38
+ export type CallStageBreakouts = {
39
+ /** False until the first describe has landed; the panel says so. */
40
+ ready: boolean;
41
+ /** This seat is the host. A rendering hint; the server decides. */
42
+ canManage: boolean;
43
+ /** Any room is open right now. */
44
+ active: boolean;
45
+ /** The room this seat is assigned to. `null` is the main room. */
46
+ current: { publicId: string; name: string } | null;
47
+ main: { participantCount: number; participants: readonly BreakoutParticipant[] };
48
+ rooms: readonly BreakoutRoomSummary[];
49
+ /** A request is in flight. Holds the controls rather than letting one be pressed twice. */
50
+ pending: boolean;
51
+ create: (input: { count: number; assign: "auto" | "manual" }) => Promise<void>;
52
+ assign: (identity: string, roomPublicId: string | null) => Promise<void>;
53
+ /** Self-move. Resolves once the ASSIGNMENT is written; the stage then calls room.moveTo(). */
54
+ moveSelf: (roomPublicId: string | null) => Promise<void>;
55
+ closeAll: () => Promise<void>;
56
+ refresh: () => void;
57
+ };
@@ -241,6 +241,8 @@ export type CallStatus = {
241
241
  tone: CallStatusTone;
242
242
  /** Always shown. Connection state is CONTENT, never a help hint. `forge.call_stage.*`. */
243
243
  headlineKey: string;
244
+ /** Placeholders for `headlineKey` (a breakout room's name). Absent for every other headline. */
245
+ headlineVars?: Record<string, string | number>;
244
246
  /** Our own second line, when there is one. `forge.call_stage.*`. */
245
247
  detailKey?: string;
246
248
  /** The server's own words when it has any. Shown verbatim. */
@@ -264,11 +266,32 @@ export type CallStatusInput = {
264
266
  * offers a control rather than a spinner.
265
267
  */
266
268
  recovering?: boolean;
269
+ /**
270
+ * A breakout move in flight: the node asked this seat to change rooms
271
+ * (`RoomState.moving`), or the person pressed Join / Return themselves.
272
+ * The SDK reports the reconnect with the same `draining` cause a server
273
+ * drain uses, so without this the pill would say "Moving you to another
274
+ * server" about a move to Room 2. `name` null means back to the main room.
275
+ */
276
+ moving?: BreakoutMove | null;
267
277
  };
268
278
 
279
+ /** What the stage knows about a move: where to, by display name when it has one. */
280
+ export type BreakoutMove = { room: string; name: string | null };
281
+
269
282
  const ENDED = "forge.call_stage.status_ended";
270
283
  const LEFT = "forge.call_stage.status_left";
271
284
 
285
+ /**
286
+ * The headline for a breakout move, shared by the pill and the centre notice
287
+ * so the two can never disagree about where somebody is going.
288
+ */
289
+ export function breakoutMoveHeadline(moving: BreakoutMove): Pick<CallStatus, "headlineKey" | "headlineVars"> {
290
+ return moving.name === null
291
+ ? { headlineKey: "forge.call_stage.breakouts.status_returning" }
292
+ : { headlineKey: "forge.call_stage.breakouts.status_moving", headlineVars: { room: moving.name } };
293
+ }
294
+
272
295
  /**
273
296
  * The room's states, told honestly.
274
297
  *
@@ -338,12 +361,19 @@ export function callStatus(input: CallStatusInput): CallStatus {
338
361
  // ordinary deploy would otherwise end every call on that node under a
339
362
  // spinner saying nobody was dropped.
340
363
  return recovering
341
- ? {
342
- tone: "reconnecting",
343
- headlineKey: "forge.call_stage.status_moving",
344
- detailKey: "forge.call_stage.status_moving_detail",
345
- canRetry: false,
346
- }
364
+ ? input.moving
365
+ ? {
366
+ tone: "reconnecting",
367
+ ...breakoutMoveHeadline(input.moving),
368
+ detailKey: "forge.call_stage.breakouts.status_moving_detail",
369
+ canRetry: false,
370
+ }
371
+ : {
372
+ tone: "reconnecting",
373
+ headlineKey: "forge.call_stage.status_moving",
374
+ detailKey: "forge.call_stage.status_moving_detail",
375
+ canRetry: false,
376
+ }
347
377
  : {
348
378
  tone: "failed",
349
379
  headlineKey: "forge.call_stage.status_move_failed",
@@ -406,13 +436,15 @@ export function canPublishSource(grants: MediaGrants | undefined, source: Publis
406
436
  return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
407
437
  }
408
438
 
409
- export type StageNoticeKind = "joining" | "waiting" | "everyone_left" | "ended" | "failed";
439
+ export type StageNoticeKind = "joining" | "waiting" | "everyone_left" | "moving" | "ended" | "failed";
410
440
 
411
441
  export type StageNotice = {
412
442
  kind: StageNoticeKind;
413
443
  /** `forge.call_stage.*` keys. The status pill's copy is reused where the
414
444
  * meaning is the same, so the two can never say different things. */
415
445
  headlineKey: string;
446
+ /** Placeholders for `headlineKey`. Only a breakout move has any. */
447
+ headlineVars?: Record<string, string | number>;
416
448
  detailKey?: string;
417
449
  /** The server's own sentence, verbatim, exactly as the status line shows it. */
418
450
  detailText?: string;
@@ -429,6 +461,18 @@ export type StageNotice = {
429
461
  * transient ones (connecting, reconnecting) keep the top pill so the stage
430
462
  * behind them stays visible; and an empty live room says "waiting", which is
431
463
  * a state, not an error.
464
+ *
465
+ * ## Breakout rooms change two answers
466
+ *
467
+ * A MOVE between rooms is a leave and a reconnect, so for a second or two the
468
+ * stage is empty and reconnecting, which without `moving` reads as "everyone
469
+ * left". The move wins over every non-terminal notice: "Moving you to Room 2"
470
+ * is the truth, and a terminal state (ended, failed) still wins over it,
471
+ * because a move that failed is the thing to act on.
472
+ *
473
+ * And while rooms are OPEN, an empty stage is "waiting", never "everyone
474
+ * left": a host who hopped into Room 3 and found nobody there yet did not end
475
+ * the call, and the people they left in the main room are still on it.
432
476
  */
433
477
  export function stageNotice(input: {
434
478
  status: CallStatus;
@@ -437,33 +481,30 @@ export function stageNotice(input: {
437
481
  /** Has anyone else ever been here this session? Separates the room that has
438
482
  * not started from the room that just emptied. */
439
483
  everSawPeers: boolean;
484
+ /** A breakout move in flight (`RoomState.moving`, or the person's own press). */
485
+ moving?: BreakoutMove | null;
486
+ /** Breakout rooms are open somewhere in this call. */
487
+ breakoutsActive?: boolean;
440
488
  }): StageNotice | null {
441
489
  const { status } = input;
442
490
 
443
- if (status.tone === "connecting") {
444
- return { kind: "joining", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
491
+ if (status.tone === "ended" || status.tone === "failed") {
492
+ return terminalNotice(status);
445
493
  }
446
- if (status.tone === "ended") {
494
+ if (input.moving) {
447
495
  return {
448
- kind: "ended",
449
- headlineKey: status.headlineKey,
450
- ...(status.detailKey ? { detailKey: status.detailKey } : {}),
451
- ...(status.detailText ? { detailText: status.detailText } : {}),
496
+ kind: "moving",
497
+ ...breakoutMoveHeadline(input.moving),
498
+ detailKey: "forge.call_stage.breakouts.status_moving_detail",
452
499
  canRetry: false,
453
500
  };
454
501
  }
455
- if (status.tone === "failed") {
456
- return {
457
- kind: "failed",
458
- headlineKey: status.headlineKey,
459
- ...(status.detailKey ? { detailKey: status.detailKey } : {}),
460
- ...(status.detailText ? { detailText: status.detailText } : {}),
461
- canRetry: status.canRetry,
462
- };
502
+ if (status.tone === "connecting") {
503
+ return { kind: "joining", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
463
504
  }
464
505
  // Live or reconnecting: the stage stands. Only an EMPTY one says anything.
465
506
  if (input.remoteCount === 0) {
466
- return input.everSawPeers
507
+ return input.everSawPeers && !input.breakoutsActive
467
508
  ? { kind: "everyone_left", headlineKey: "forge.call_stage.everyone_left", canRetry: false }
468
509
  : {
469
510
  kind: "waiting",
@@ -474,3 +515,22 @@ export function stageNotice(input: {
474
515
  }
475
516
  return null;
476
517
  }
518
+
519
+ function terminalNotice(status: CallStatus): StageNotice {
520
+ if (status.tone === "ended") {
521
+ return {
522
+ kind: "ended",
523
+ headlineKey: status.headlineKey,
524
+ ...(status.detailKey ? { detailKey: status.detailKey } : {}),
525
+ ...(status.detailText ? { detailText: status.detailText } : {}),
526
+ canRetry: false,
527
+ };
528
+ }
529
+ return {
530
+ kind: "failed",
531
+ headlineKey: status.headlineKey,
532
+ ...(status.detailKey ? { detailKey: status.detailKey } : {}),
533
+ ...(status.detailText ? { detailText: status.detailText } : {}),
534
+ canRetry: status.canRetry,
535
+ };
536
+ }
@@ -49,6 +49,9 @@ export {
49
49
  useLocalMedia,
50
50
  useLocalPublications,
51
51
  useMediaRoom,
52
+ // A breakout move the node asked for (`moveRequested`), until the next join.
53
+ // `CallStage` reads it; a site laying out its own stage wants it by name.
54
+ useMoving,
52
55
  useParticipants,
53
56
  useRecording,
54
57
  useRemoteTrack,
@@ -131,6 +134,13 @@ export { PreJoin, type PreJoinProps, type PreJoinSettings } from "./PreJoin";
131
134
  */
132
135
  export type { CallChatMessage, CallStageChat } from "./callChat";
133
136
 
137
+ /**
138
+ * The breakout-rooms adapter's shape, injected the same way as `chat`. The
139
+ * public lane's `useCallBreakouts` (from the root entry) produces it; admin
140
+ * composes its own against the dashboard's client.
141
+ */
142
+ export type { BreakoutParticipant, BreakoutRoomSummary, CallStageBreakouts } from "./callBreakouts";
143
+
134
144
  /** The reaction vocabulary, shared by the picker and the receive whitelist. */
135
145
  export { REACTION_EMOJI, type ReactionEmoji } from "./stage/callSignals";
136
146
 
@@ -149,6 +159,7 @@ export {
149
159
  callTiles,
150
160
  canPublishSource,
151
161
  remoteAudioProducerIds,
162
+ type BreakoutMove,
152
163
  type CallStatus,
153
164
  type CallStatusInput,
154
165
  type CallStatusTone,