@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
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  import { useForgeT, type ForgeT } from "../../i18n";
19
19
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
20
+ import type { CallStageBreakouts } from "./callBreakouts";
20
21
  import type { CallStageChat } from "./callChat";
21
22
  import {
22
23
  callHeadCount,
@@ -24,10 +25,11 @@ import {
24
25
  callTiles,
25
26
  remoteAudioProducerIds,
26
27
  stageNotice,
28
+ type BreakoutMove,
27
29
  type CallTile,
28
30
  } from "./callState";
29
31
  import { useFlyingReactions, useRaisedHands } from "./hooks/useCallSignals";
30
- import { CallSidePanel, type SidePanelTab } from "./panels/CallSidePanel";
32
+ import { CallSidePanel, showsRoomsTab, type SidePanelTab } from "./panels/CallSidePanel";
31
33
  import { resolveLayout, GALLERY_PAGE_SIZE, pickDominant, type StageLayout, type ViewChoice } from "./stage/callLayout";
32
34
  import { diffPeers, unreadCount } from "./stage/callSignals";
33
35
  import { createCallSounds } from "./stage/callSounds";
@@ -96,6 +98,12 @@ export type CallStageProps = {
96
98
  * how. Absent means no chat tab or button is drawn.
97
99
  */
98
100
  chat?: CallStageChat;
101
+ /**
102
+ * The breakout-rooms adapter, injected like `chat`. Absent means no rooms
103
+ * tab, no Rooms button and no Return to main: the stage draws exactly what
104
+ * it drew before rooms existed.
105
+ */
106
+ breakouts?: CallStageBreakouts;
99
107
  /** Rendering hint for host courtesies (lower-hand). The server enforces
100
108
  * every real host action itself. */
101
109
  isHost?: boolean;
@@ -161,6 +169,7 @@ export function CallStage({
161
169
  aspectRatio = 16 / 9,
162
170
  recordingControl,
163
171
  chat,
172
+ breakouts,
164
173
  isHost = false,
165
174
  mediaDefaults,
166
175
  cameraSimulcast = false,
@@ -168,7 +177,7 @@ export function CallStage({
168
177
  const themeTokens = useThemeTokens();
169
178
  const tokens = useMemo(() => stageTokens(themeTokens), [themeTokens]);
170
179
  const t = useForgeT();
171
- const { connectionState, error, recovering, retry } = useMediaRoom();
180
+ const { connectionState, error, recovering, retry, room } = useMediaRoom();
172
181
  const state = useRoomState();
173
182
  const peers = useParticipants();
174
183
  const activeSpeakers = useActiveSpeakers();
@@ -185,9 +194,23 @@ export function CallStage({
185
194
 
186
195
  const [ui, dispatch] = useReducer(uiReducer, initialUi);
187
196
 
197
+ /**
198
+ * A breakout move in flight, from either side.
199
+ *
200
+ * The node's `moveRequested` lands in `state.moving` (the host sent us
201
+ * somewhere); a Join or Return the person pressed themselves goes through
202
+ * `room.moveTo()`, which the SDK reports exactly like a server drain, so the
203
+ * stage remembers the destination itself in `localMoving` until the room is
204
+ * back. Both read the same way everywhere below.
205
+ */
206
+ const [localMoving, setLocalMoving] = useState<BreakoutMove | null>(null);
207
+ const [movePending, setMovePending] = useState(false);
208
+ const [moveError, setMoveError] = useState<string | null>(null);
209
+ const moving: BreakoutMove | null = state.moving ?? localMoving;
210
+
188
211
  const status = useMemo(
189
- () => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
190
- [connectionState, error, recovering, state.phase, state.closedReason],
212
+ () => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason, moving }),
213
+ [connectionState, error, recovering, state.phase, state.closedReason, moving],
191
214
  );
192
215
 
193
216
  // Everyone by name, INCLUDING self as "You" - the peer list is other people
@@ -211,11 +234,54 @@ export function CallStage({
211
234
  const remoteCount = useMemo(() => new Set(tiles.map((tile) => tile.identity)).size, [tiles]);
212
235
  const everSawPeersRef = useRef(false);
213
236
  if (remoteCount > 0) everSawPeersRef.current = true;
237
+ const breakoutsActive = breakouts?.active ?? false;
214
238
  const notice = useMemo(
215
- () => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current }),
216
- [status, remoteCount],
239
+ () => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current, moving, breakoutsActive }),
240
+ [status, remoteCount, moving, breakoutsActive],
241
+ );
242
+
243
+ /**
244
+ * The self-move: write the assignment, then leave and reconnect. The backend
245
+ * mints the next ticket for the room the assignment names, so the reconnect
246
+ * IS the move; captures survive it the way they survive a drain.
247
+ */
248
+ const selfMove = useCallback(
249
+ async (roomPublicId: string | null) => {
250
+ if (!breakouts || !room) return;
251
+ setMoveError(null);
252
+ setMovePending(true);
253
+ try {
254
+ await breakouts.moveSelf(roomPublicId);
255
+ const name = roomPublicId ? (breakouts.rooms.find((r) => r.publicId === roomPublicId)?.name ?? roomPublicId) : null;
256
+ setLocalMoving({ room: roomPublicId ?? "main", name });
257
+ await room.moveTo();
258
+ } catch (cause) {
259
+ setLocalMoving(null);
260
+ setMoveError(cause instanceof Error && cause.message ? cause.message : t("forge.call_stage.breakouts.error"));
261
+ } finally {
262
+ setMovePending(false);
263
+ }
264
+ },
265
+ [breakouts, room, t],
217
266
  );
218
267
 
268
+ // The room is back (or gone): the local destination has been reached or is
269
+ // moot, and the assignment view is re-read so the panel shows where everyone
270
+ // landed. Every RETURN to connected refreshes, not only after our own move:
271
+ // the host may have reassigned us while we were away.
272
+ const previousConnectionRef = useRef(connectionState);
273
+ const refreshRef = useRef(breakouts?.refresh);
274
+ refreshRef.current = breakouts?.refresh;
275
+ useEffect(() => {
276
+ const previous = previousConnectionRef.current;
277
+ previousConnectionRef.current = connectionState;
278
+ if (connectionState === "connected" && previous !== "connected") {
279
+ setLocalMoving(null);
280
+ if (previous !== "idle" && previous !== "connecting") refreshRef.current?.();
281
+ }
282
+ if (connectionState === "closed") setLocalMoving(null);
283
+ }, [connectionState]);
284
+
219
285
  // Dominant speaker with a hold, so the rail and speaker view do not flap.
220
286
  const [dominant, setDominant] = useState<{ identity: string | null; since: number }>({ identity: null, since: 0 });
221
287
  useEffect(() => {
@@ -308,6 +374,23 @@ export function CallStage({
308
374
 
309
375
  const togglePanel = (tab: SidePanelTab) => dispatch({ type: "panel", panel: ui.panel === tab ? null : tab });
310
376
 
377
+ // A tab whose content went away (chat never injected, rooms closed under an
378
+ // attendee) falls back to People rather than drawing an empty rail.
379
+ const roomsTab = showsRoomsTab(breakouts);
380
+ const panelTab: SidePanelTab | null =
381
+ ui.panel === "chat" && !chat ? "people" : ui.panel === "rooms" && !roomsTab ? "people" : ui.panel;
382
+
383
+ // An attendee's rooms list is read on demand: once when the tab opens and
384
+ // then every few seconds while it stays open. The host's adapter polls on
385
+ // its own, so this only fires for a seat whose adapter does not.
386
+ const attendeeRoomsOpen = panelTab === "rooms" && !!breakouts && !breakouts.canManage;
387
+ useEffect(() => {
388
+ if (!attendeeRoomsOpen) return undefined;
389
+ refreshRef.current?.();
390
+ const timer = setInterval(() => refreshRef.current?.(), 5000);
391
+ return () => clearInterval(timer);
392
+ }, [attendeeRoomsOpen]);
393
+
311
394
  const showSelfView =
312
395
  !ui.selfHidden && layout.mode !== "duo" && connectionState === "connected" && (!notice || notice.kind === "waiting" || notice.kind === "everyone_left");
313
396
 
@@ -410,7 +493,7 @@ export function CallStage({
410
493
  {ui.panel && narrow && (
411
494
  <div data-testid="call-panel-overlay" style={{ position: "absolute", inset: 0, zIndex: 30, display: "flex" }}>
412
495
  <CallSidePanel
413
- tab={chat ? ui.panel : "people"}
496
+ tab={panelTab ?? "people"}
414
497
  onTab={(tab) => dispatch({ type: "panel", panel: tab })}
415
498
  onClose={() => dispatch({ type: "panel", panel: null })}
416
499
  tokens={tokens}
@@ -423,6 +506,10 @@ export function CallStage({
423
506
  onLowerHand={hands.lowerHandOf}
424
507
  onLowerAll={hands.lowerAll}
425
508
  unread={chatUnread}
509
+ breakouts={breakouts}
510
+ onSelfMove={(id) => void selfMove(id)}
511
+ movePending={movePending}
512
+ moveError={moveError}
426
513
  fill
427
514
  />
428
515
  </div>
@@ -431,7 +518,7 @@ export function CallStage({
431
518
 
432
519
  {ui.panel && !narrow && (
433
520
  <CallSidePanel
434
- tab={chat ? ui.panel : "people"}
521
+ tab={panelTab ?? "people"}
435
522
  onTab={(tab) => dispatch({ type: "panel", panel: tab })}
436
523
  onClose={() => dispatch({ type: "panel", panel: null })}
437
524
  tokens={tokens}
@@ -444,6 +531,10 @@ export function CallStage({
444
531
  onLowerHand={hands.lowerHandOf}
445
532
  onLowerAll={hands.lowerAll}
446
533
  unread={chatUnread}
534
+ breakouts={breakouts}
535
+ onSelfMove={(id) => void selfMove(id)}
536
+ movePending={movePending}
537
+ moveError={moveError}
447
538
  />
448
539
  )}
449
540
  </div>
@@ -466,8 +557,14 @@ export function CallStage({
466
557
  chatUnread={chatUnread}
467
558
  onToggleChat={() => togglePanel("chat")}
468
559
  onTogglePeople={() => togglePanel("people")}
469
- chatOpen={ui.panel === "chat"}
470
- peopleOpen={ui.panel === "people"}
560
+ chatOpen={panelTab === "chat"}
561
+ peopleOpen={panelTab === "people"}
562
+ roomsAvailable={roomsTab}
563
+ roomsOpen={panelTab === "rooms"}
564
+ onToggleRooms={() => togglePanel("rooms")}
565
+ inBreakout={!!breakouts?.current}
566
+ onReturnToMain={() => void selfMove(null)}
567
+ movePending={movePending}
471
568
  onGesture={unlockSounds}
472
569
  />
473
570
 
@@ -0,0 +1,271 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { translateForge, type ForgeT } from "../../../i18n";
6
+ import type { CallStageBreakouts } from "../callBreakouts";
7
+ import { CallSidePanel, showsRoomsTab } from "../panels/CallSidePanel";
8
+ import type { StageTokens } from "../stage/stageTheme";
9
+
10
+ /**
11
+ * The rooms rail, rendered against a fake adapter.
12
+ *
13
+ * Three things are worth being sure about here, and none of them is "did it
14
+ * render": that a call WITHOUT rooms draws exactly the two tabs it always drew,
15
+ * that the host's controls reach the adapter with the right arguments (an
16
+ * assignment sent to the wrong room is silent), and that closing every room
17
+ * takes a second press.
18
+ */
19
+
20
+ afterEach(cleanup);
21
+
22
+ const tokens: StageTokens = {
23
+ stageBg: "#0c0d10",
24
+ stageSurface: "#17181d",
25
+ stageChrome: "#1e2026",
26
+ stageText: "#f4f5f7",
27
+ stageMuted: "#9aa0ab",
28
+ stageBorder: "#2c2f37",
29
+ accent: "#0b6",
30
+ accentText: "#fff",
31
+ danger: "#c00",
32
+ cornerRadius: 8,
33
+ };
34
+
35
+ const t: ForgeT = (key, vars) => translateForge("en", key, vars);
36
+
37
+ const adapter = (over: Partial<CallStageBreakouts> = {}): CallStageBreakouts => ({
38
+ ready: true,
39
+ canManage: false,
40
+ active: false,
41
+ current: null,
42
+ main: { participantCount: 0, participants: [] },
43
+ rooms: [],
44
+ pending: false,
45
+ create: vi.fn(async () => undefined),
46
+ assign: vi.fn(async () => undefined),
47
+ moveSelf: vi.fn(async () => undefined),
48
+ closeAll: vi.fn(async () => undefined),
49
+ refresh: vi.fn(),
50
+ ...over,
51
+ });
52
+
53
+ const ROOMS = [
54
+ {
55
+ publicId: "room-1",
56
+ name: "Room 1",
57
+ position: 1,
58
+ participantCount: 1,
59
+ participants: [{ identity: "anna", name: "Anna" }],
60
+ assigned: [
61
+ { identity: "anna", name: "Anna" },
62
+ { identity: "ben", name: "Ben" },
63
+ ],
64
+ },
65
+ { publicId: "room-2", name: "Room 2", position: 2, participantCount: 0, participants: [], assigned: [] },
66
+ ];
67
+
68
+ const mount = (
69
+ props: { breakouts?: CallStageBreakouts; tab?: "chat" | "people" | "rooms"; onSelfMove?: (id: string | null) => void } = {},
70
+ ) => {
71
+ const onTab = vi.fn();
72
+ const utils = render(
73
+ <CallSidePanel
74
+ tab={props.tab ?? "rooms"}
75
+ onTab={onTab}
76
+ onClose={() => undefined}
77
+ tokens={tokens}
78
+ t={t}
79
+ tiles={[]}
80
+ hands={[]}
81
+ isHost={!!props.breakouts?.canManage}
82
+ myIdentity="host-1"
83
+ onLowerHand={() => undefined}
84
+ onLowerAll={() => undefined}
85
+ unread={0}
86
+ breakouts={props.breakouts}
87
+ onSelfMove={props.onSelfMove ?? (() => undefined)}
88
+ movePending={false}
89
+ moveError={null}
90
+ />,
91
+ );
92
+ return { ...utils, onTab };
93
+ };
94
+
95
+ describe("the rooms tab exists only when there is something behind it", () => {
96
+ it("draws no rooms tab and no rooms panel when the page injected no adapter", () => {
97
+ const { queryByText, queryByTestId, getByTestId } = mount({ breakouts: undefined, tab: "rooms" });
98
+ expect(showsRoomsTab(undefined)).toBe(false);
99
+ expect(queryByText("Rooms")).toBeNull();
100
+ expect(queryByTestId("call-breakouts-panel")).toBeNull();
101
+ // A rooms tab asked for with nothing behind it falls back to the roster.
102
+ expect(getByTestId("call-participants-panel")).toBeTruthy();
103
+ });
104
+
105
+ it("hides the tab from an attendee while no room is open, and shows it once one is", () => {
106
+ expect(showsRoomsTab(adapter({ canManage: false, active: false }))).toBe(false);
107
+ expect(showsRoomsTab(adapter({ canManage: false, active: true }))).toBe(true);
108
+ // The host always has it: that is where rooms get created.
109
+ expect(showsRoomsTab(adapter({ canManage: true, active: false }))).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe("the host's view", () => {
114
+ it("offers the create form while no room is open and sends count and assignment", async () => {
115
+ const breakouts = adapter({ canManage: true });
116
+ const { getByLabelText, getByText, queryByTestId } = mount({ breakouts });
117
+
118
+ expect(queryByTestId("breakouts-close-all")).toBeNull();
119
+ fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "3" } });
120
+ fireEvent.change(getByLabelText("Assign people"), { target: { value: "manual" } });
121
+ fireEvent.click(getByText("Create rooms"));
122
+
123
+ await waitFor(() => expect(breakouts.create).toHaveBeenCalledWith({ count: 3, assign: "manual" }));
124
+ });
125
+
126
+ it("refuses a count outside 1..20 without asking the server", () => {
127
+ const breakouts = adapter({ canManage: true });
128
+ const { getByLabelText, getByText } = mount({ breakouts });
129
+ fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "21" } });
130
+ expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
131
+ fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "0" } });
132
+ expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
133
+ expect(breakouts.create).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("lists the main room and every room with live counts, arrivals and the people on their way", () => {
137
+ const breakouts = adapter({
138
+ canManage: true,
139
+ active: true,
140
+ main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
141
+ rooms: ROOMS,
142
+ });
143
+ const { getByTestId } = mount({ breakouts });
144
+
145
+ const main = getByTestId("breakouts-room-main");
146
+ expect(main.textContent).toContain("Main room");
147
+ expect(main.textContent).toContain("2 in this room");
148
+ expect(main.textContent).toContain("Host (you)");
149
+ expect(main.textContent).toContain("Carla");
150
+
151
+ const room1 = getByTestId("breakouts-room-room-1");
152
+ expect(room1.textContent).toContain("Room 1");
153
+ expect(room1.textContent).toContain("1 in this room");
154
+ expect(room1.textContent).toContain("Anna");
155
+ // Assigned, not yet arrived: listed where they are GOING, and said so.
156
+ expect(room1.textContent).toContain("Ben (on the way)");
157
+
158
+ expect(getByTestId("breakouts-room-room-2").textContent).toContain("Nobody here yet");
159
+ });
160
+
161
+ it("assigns a person through the select beside their name, and never offers one for the host themselves", async () => {
162
+ const breakouts = adapter({
163
+ canManage: true,
164
+ active: true,
165
+ main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
166
+ rooms: ROOMS,
167
+ });
168
+ const { getByLabelText, queryByLabelText } = mount({ breakouts });
169
+
170
+ fireEvent.change(getByLabelText("Move Carla to a room"), { target: { value: "room-2" } });
171
+ await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("carla", "room-2"));
172
+
173
+ // Back to main is `null`, not the empty string the option carries.
174
+ fireEvent.change(getByLabelText("Move Anna to a room"), { target: { value: "" } });
175
+ await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("anna", null));
176
+
177
+ // The host moves themselves with Join, never by reassigning their own row.
178
+ expect(queryByLabelText("Move Host to a room")).toBeNull();
179
+ });
180
+
181
+ it("joins a room through the stage's self-move, and marks the room the host is in", () => {
182
+ const onSelfMove = vi.fn();
183
+ const breakouts = adapter({ canManage: true, active: true, current: { publicId: "room-1", name: "Room 1" }, rooms: ROOMS });
184
+ const { getByTestId, queryByTestId } = mount({ breakouts, onSelfMove });
185
+
186
+ // In Room 1: no Join for it, a marker instead; Join for the other room and
187
+ // Return to main for the main room.
188
+ expect(queryByTestId("breakouts-join-room-1")).toBeNull();
189
+ expect(getByTestId("breakouts-room-room-1").textContent).toContain("You are here");
190
+
191
+ fireEvent.click(getByTestId("breakouts-join-room-2"));
192
+ expect(onSelfMove).toHaveBeenCalledWith("room-2");
193
+ fireEvent.click(getByTestId("breakouts-join-main"));
194
+ expect(onSelfMove).toHaveBeenCalledWith(null);
195
+ // The panel does not touch the adapter for a self-move; the stage does.
196
+ expect(breakouts.moveSelf).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("closes every room only after the warning has been confirmed", async () => {
200
+ const breakouts = adapter({ canManage: true, active: true, rooms: ROOMS });
201
+ const { getByTestId, getByText, queryByTestId, queryByText } = mount({ breakouts });
202
+
203
+ // The warning is not on screen until the control is pressed.
204
+ expect(queryByText(/Everyone comes back to the main room/)).toBeNull();
205
+ fireEvent.click(getByTestId("breakouts-close-all"));
206
+ expect(breakouts.closeAll).not.toHaveBeenCalled();
207
+ // Visible as content: a warning about what a control is about to do is
208
+ // never a `?` hint.
209
+ expect(getByText(/Everyone comes back to the main room/)).toBeTruthy();
210
+
211
+ fireEvent.click(getByText("Keep them open"));
212
+ expect(breakouts.closeAll).not.toHaveBeenCalled();
213
+ expect(queryByTestId("breakouts-close-confirm")).toBeNull();
214
+
215
+ fireEvent.click(getByTestId("breakouts-close-all"));
216
+ fireEvent.click(getByTestId("breakouts-close-confirm"));
217
+ await waitFor(() => expect(breakouts.closeAll).toHaveBeenCalledTimes(1));
218
+ });
219
+
220
+ it("shows the server's own sentence when an action is refused", async () => {
221
+ const breakouts = adapter({
222
+ canManage: true,
223
+ create: vi.fn(async () => {
224
+ throw new Error("Rooms are already open.");
225
+ }),
226
+ });
227
+ const { getByText, getByTestId } = mount({ breakouts });
228
+ fireEvent.click(getByText("Create rooms"));
229
+ await waitFor(() => expect(getByTestId("breakouts-error").textContent).toBe("Rooms are already open."));
230
+ });
231
+
232
+ it("keeps the explanation behind a `?` with a name that says what it explains", async () => {
233
+ const { getByLabelText, queryByText } = mount({ breakouts: adapter({ canManage: true }) });
234
+ expect(queryByText(/Each room is a separate call/)).toBeNull();
235
+ fireEvent.click(getByLabelText("What happens when I create rooms?"));
236
+ await waitFor(() => expect(queryByText(/Each room is a separate call/)).toBeTruthy());
237
+ });
238
+ });
239
+
240
+ describe("the attendee's view", () => {
241
+ it("says where you are, lists the rooms, and offers no host control", () => {
242
+ const breakouts = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
243
+ const { getByTestId, queryByTestId, queryByLabelText, queryByText } = mount({ breakouts });
244
+
245
+ expect(getByTestId("breakouts-where").textContent).toBe("You are in Room 2");
246
+ expect(queryByText("Room 1")).toBeTruthy();
247
+ expect(queryByText("Main room")).toBeTruthy();
248
+ expect(queryByTestId("breakouts-close-all")).toBeNull();
249
+ expect(queryByTestId("breakouts-create")).toBeNull();
250
+ expect(queryByLabelText(/Move .* to a room/)).toBeNull();
251
+ });
252
+
253
+ it("offers Return to main room only while in a breakout, and routes it through the stage", () => {
254
+ const onSelfMove = vi.fn();
255
+ const inRoom = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
256
+ const { getByTestId, unmount } = mount({ breakouts: inRoom, onSelfMove });
257
+ fireEvent.click(getByTestId("breakouts-return-main"));
258
+ expect(onSelfMove).toHaveBeenCalledWith(null);
259
+ unmount();
260
+
261
+ const inMain = adapter({ canManage: false, active: true, current: null, rooms: ROOMS });
262
+ const { queryByTestId, getByTestId: get } = mount({ breakouts: inMain });
263
+ expect(queryByTestId("breakouts-return-main")).toBeNull();
264
+ expect(get("breakouts-where").textContent).toBe("You are in the main room");
265
+ });
266
+
267
+ it("says it is loading before the first describe has landed", () => {
268
+ const { getByText } = mount({ breakouts: adapter({ ready: false, active: true }) });
269
+ expect(getByText("Loading rooms…")).toBeTruthy();
270
+ });
271
+ });