@tribe-nest/forge 3.57.0 → 3.59.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 (71) 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/useEvents.ts +68 -0
  7. package/src/data/queries/useMyBookings.ts +16 -0
  8. package/src/data/queries/useNotifications.ts +1 -1
  9. package/src/i18n/de.json +79 -0
  10. package/src/i18n/en.json +79 -0
  11. package/src/index.ts +1 -0
  12. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  13. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  14. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  15. package/src/server/index.ts +1 -1
  16. package/src/server/pwa.ts +1 -1
  17. package/src/types/models.ts +10 -0
  18. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  19. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  20. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  21. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  22. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  23. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  24. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  25. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  26. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  27. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  28. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  29. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  30. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  31. package/src/ui/headless/event/useEventSeating.ts +233 -0
  32. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  33. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  34. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  35. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  36. package/src/ui/headless/index.ts +25 -1
  37. package/src/ui/index.ts +7 -0
  38. package/src/ui/media/CallStage.tsx +107 -10
  39. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  40. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  41. package/src/ui/media/_tests/callState.spec.ts +103 -1
  42. package/src/ui/media/callBreakouts.ts +57 -0
  43. package/src/ui/media/callState.ts +83 -23
  44. package/src/ui/media/index.ts +11 -0
  45. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  46. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  47. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  48. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  49. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  50. package/src/ui/shell/TribeNestApp.tsx +1 -1
  51. package/src/ui/styled/AccountDashboard.tsx +5 -0
  52. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  53. package/src/ui/styled/Cart.tsx +211 -135
  54. package/src/ui/styled/Checkout.tsx +163 -32
  55. package/src/ui/styled/EmailListForm.tsx +7 -9
  56. package/src/ui/styled/EventTickets.tsx +143 -4
  57. package/src/ui/styled/PhoneInput.tsx +127 -0
  58. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  59. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  60. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  61. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  62. package/src/ui/styled/forge-utilities.css +67 -0
  63. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  64. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  65. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  66. package/src/ui/styled/seating/paint.ts +79 -0
  67. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  68. package/src/ui/styled/seating/viewport.ts +86 -0
  69. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  70. package/src/utils/engagementTracker.ts +73 -0
  71. package/src/utils/landing.ts +1 -1
@@ -0,0 +1,294 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { CountryCode, Examples } from "libphonenumber-js";
3
+
4
+ /**
5
+ * Phone entry that produces E.164, because the input is the only place the
6
+ * country is knowable.
7
+ *
8
+ * ## Why this exists at all
9
+ *
10
+ * `services/admin/consent/phone.ts` refuses to infer a country code from a bare
11
+ * national number, and it is right to: guessing `+1` for a ten-digit string is
12
+ * wrong for most of the world, and a wrong guess writes a phantom suppression
13
+ * against a number that never opted out. The consequence it accepts is that a
14
+ * bare national number simply never matches an E.164 one.
15
+ *
16
+ * That consequence is a live compliance hole on the way IN. Someone types
17
+ * `07700 900123` into a signup form, it is stored verbatim, they later text
18
+ * STOP from `+447700900123`, and the suppression lookup misses. We keep texting
19
+ * someone who said stop, which is the single most expensive failure in SMS.
20
+ *
21
+ * Nothing downstream can fix that, because by then the country is gone. So the
22
+ * fix belongs here: ask for the country, and emit E.164 from the start.
23
+ *
24
+ * ## Metadata cost, measured
25
+ *
26
+ * `libphonenumber-js/max` is 55.5 KB gzip / 48.1 KB brotli once bundled, and the
27
+ * metadata is effectively the whole of it: a build importing only `AsYouType`
28
+ * and `parsePhoneNumberFromString` measured 35.5 KB gzip against 35.7 KB for the
29
+ * full `min` import, so there is nothing to win by trimming the API surface.
30
+ *
31
+ * `max` rather than the smaller tiers, and the reason is not the line-type
32
+ * warning. `mobile` is a mobile-ONLY dataset: under it a landline is not typed
33
+ * as a landline, it is INVALID. That would refuse a perfectly good courier
34
+ * number at checkout, and would refuse real customers in the many countries
35
+ * whose numbers are typed FIXED_LINE_OR_MOBILE. `min` accepts everything but
36
+ * reports the type inconsistently (a GB landline resolves, a DE one does not),
37
+ * so a warning built on it would appear or vanish by country for no reason the
38
+ * visitor could see. `max` is the only tier where both modes below are correct.
39
+ *
40
+ * It is loaded with a dynamic `import()` so none of it sits in the first paint.
41
+ * A phone field is never the thing a visitor sees first, and until the metadata
42
+ * lands the input still accepts typing: `isReady` is false, validation holds
43
+ * off, and nothing is blocked.
44
+ */
45
+
46
+ export type PhoneNumberType = "mobile" | "fixedLine" | "other" | "unknown";
47
+
48
+ export interface UsePhoneInputOptions {
49
+ /**
50
+ * Where to start the country select. Pass the most confident source you have:
51
+ * checkout knows the shipping country, a storefront knows the visitor's geo.
52
+ * Never derive this from the digits someone is typing.
53
+ */
54
+ defaultCountry?: CountryCode;
55
+ /**
56
+ * `messaging` is for a number we will text: it demands a valid number and
57
+ * warns on a line type that cannot receive SMS. `shipping` is for a number a
58
+ * courier will ring, where a landline is perfectly good and the bar is only
59
+ * that the number is real.
60
+ */
61
+ mode?: "messaging" | "shipping";
62
+ /** Seed value, E.164 or national. An E.164 seed also sets the country. */
63
+ initialValue?: string;
64
+ /** Called with E.164 (or null while incomplete) on every change. */
65
+ onChange?: (e164: string | null) => void;
66
+ /**
67
+ * Set false when the field is not being shown. Hooks cannot be called
68
+ * conditionally, so a form with an optional phone field has to call this
69
+ * every render; this is what stops it fetching ~48 KB of metadata for a field
70
+ * nobody is going to see.
71
+ */
72
+ enabled?: boolean;
73
+ }
74
+
75
+ interface Metadata {
76
+ AsYouType: typeof import("libphonenumber-js/max").AsYouType;
77
+ parsePhoneNumberFromString: typeof import("libphonenumber-js/max").parsePhoneNumberFromString;
78
+ getCountries: typeof import("libphonenumber-js/max").getCountries;
79
+ getCountryCallingCode: typeof import("libphonenumber-js/max").getCountryCallingCode;
80
+ getExampleNumber: typeof import("libphonenumber-js/max").getExampleNumber;
81
+ examples: Examples;
82
+ }
83
+
84
+ let metadataPromise: Promise<Metadata> | null = null;
85
+
86
+ /**
87
+ * One shared load for the whole page. Two phone fields on one checkout must not
88
+ * fetch the metadata twice, and the promise is cached rather than the result so
89
+ * concurrent callers await the same request.
90
+ */
91
+ function loadMetadata(): Promise<Metadata> {
92
+ if (!metadataPromise) {
93
+ metadataPromise = Promise.all([
94
+ import("libphonenumber-js/max"),
95
+ import("libphonenumber-js/examples.mobile.json"),
96
+ ]).then(([lib, ex]) => ({
97
+ AsYouType: lib.AsYouType,
98
+ parsePhoneNumberFromString: lib.parsePhoneNumberFromString,
99
+ getCountries: lib.getCountries,
100
+ getCountryCallingCode: lib.getCountryCallingCode,
101
+ getExampleNumber: lib.getExampleNumber,
102
+ examples: ((ex as { default?: Examples }).default ?? ex) as Examples,
103
+ }));
104
+ }
105
+ return metadataPromise;
106
+ }
107
+
108
+ /** Let a host warm the metadata before the field is reached (on route enter, say). */
109
+ export const preloadPhoneMetadata = loadMetadata;
110
+
111
+ export interface CountryOption {
112
+ country: CountryCode;
113
+ callingCode: string;
114
+ flag: string;
115
+ }
116
+
117
+ /** Regional-indicator pair. "GB" becomes the flag, with no icon set to ship. */
118
+ function flagOf(country: string): string {
119
+ if (country.length !== 2) return "";
120
+ return String.fromCodePoint(
121
+ ...[...country.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)),
122
+ );
123
+ }
124
+
125
+ export function usePhoneInput(opts: UsePhoneInputOptions = {}) {
126
+ const { defaultCountry, mode = "messaging", initialValue, onChange, enabled = true } = opts;
127
+
128
+ const [meta, setMeta] = useState<Metadata | null>(null);
129
+ const [country, setCountryState] = useState<CountryCode | undefined>(defaultCountry);
130
+ const [nationalValue, setNationalValue] = useState(initialValue ?? "");
131
+ const [touched, setTouched] = useState(false);
132
+ const onChangeRef = useRef(onChange);
133
+ onChangeRef.current = onChange;
134
+
135
+ useEffect(() => {
136
+ if (!enabled) return;
137
+ let alive = true;
138
+ loadMetadata().then((m) => {
139
+ if (alive) setMeta(m);
140
+ });
141
+ return () => {
142
+ alive = false;
143
+ };
144
+ }, [enabled]);
145
+
146
+ /**
147
+ * A `defaultCountry` that resolves late still has to land. Checkout learns the
148
+ * shipping country after the address is filled, so a prop that arrives second
149
+ * must move the select, but only while the visitor has not chosen for
150
+ * themselves. Overriding a deliberate choice would be worse than being wrong.
151
+ */
152
+ useEffect(() => {
153
+ if (defaultCountry && !touched && !country) setCountryState(defaultCountry);
154
+ }, [defaultCountry, touched, country]);
155
+
156
+ const parsed = useMemo(() => {
157
+ if (!meta || !nationalValue.trim()) return null;
158
+ return meta.parsePhoneNumberFromString(nationalValue, country);
159
+ }, [meta, nationalValue, country]);
160
+
161
+ const e164 = parsed?.isValid() ? (parsed.number as string) : null;
162
+
163
+ const numberType: PhoneNumberType = useMemo(() => {
164
+ if (!parsed?.isValid()) return "unknown";
165
+ const t = parsed.getType();
166
+ if (t === "MOBILE") return "mobile";
167
+ if (t === "FIXED_LINE") return "fixedLine";
168
+ if (t === "FIXED_LINE_OR_MOBILE" || t === undefined) return "unknown";
169
+ return "other";
170
+ }, [parsed]);
171
+
172
+ /**
173
+ * A landline is a warning, never an error. The line type is a best guess from
174
+ * a numbering plan that many countries do not partition cleanly, and blocking
175
+ * a real number on a guess is worse than spending one credit finding out.
176
+ */
177
+ const warning = useMemo(() => {
178
+ if (mode !== "messaging" || !e164) return null;
179
+ return numberType === "fixedLine" ? ("landline" as const) : null;
180
+ }, [mode, e164, numberType]);
181
+
182
+ const error = useMemo(() => {
183
+ if (!touched || !meta) return null;
184
+ const raw = nationalValue.trim();
185
+ if (!raw) return "required" as const;
186
+ if (!country && !raw.startsWith("+")) return "no_country" as const;
187
+ if (!parsed) return "invalid" as const;
188
+ if (!parsed.isValid()) return "invalid" as const;
189
+ return null;
190
+ }, [touched, meta, nationalValue, country, parsed]);
191
+
192
+ /**
193
+ * Format as they type, in the national style of the selected country, so the
194
+ * value on screen matches the shape the placeholder promised.
195
+ *
196
+ * The exception is a pasted international number. The field says "without the
197
+ * country code" and a good share of people will paste `+44…` regardless. Left
198
+ * alone under a `GB` formatter that would read as a national string and come
199
+ * out mangled, so a leading `+` switches to international formatting and
200
+ * re-syncs the flag to whatever was pasted.
201
+ */
202
+ const setValue = useCallback(
203
+ (raw: string) => {
204
+ if (!meta) {
205
+ setNationalValue(raw);
206
+ return;
207
+ }
208
+ const isInternational = raw.trim().startsWith("+");
209
+ if (isInternational) {
210
+ const formatter = new meta.AsYouType();
211
+ const formatted = formatter.input(raw);
212
+ const detected = formatter.getCountry();
213
+ if (detected && detected !== country) {
214
+ setCountryState(detected);
215
+ setTouched(true);
216
+ }
217
+ setNationalValue(formatted);
218
+ return;
219
+ }
220
+ const formatter = new meta.AsYouType(country);
221
+ setNationalValue(formatter.input(raw));
222
+ },
223
+ [meta, country],
224
+ );
225
+
226
+ const setCountry = useCallback(
227
+ (next: CountryCode) => {
228
+ setCountryState(next);
229
+ setTouched(true);
230
+ // Re-format what is already typed under the new country rather than
231
+ // clearing it: someone correcting the flag after typing should not lose
232
+ // their number.
233
+ setNationalValue((current) => {
234
+ if (!meta || !current.trim() || current.trim().startsWith("+")) return current;
235
+ const digits = current.replace(/\D/g, "");
236
+ return new meta.AsYouType(next).input(digits);
237
+ });
238
+ },
239
+ [meta],
240
+ );
241
+
242
+ useEffect(() => {
243
+ onChangeRef.current?.(e164);
244
+ }, [e164]);
245
+
246
+ const countries: CountryOption[] = useMemo(() => {
247
+ if (!meta) return [];
248
+ return meta
249
+ .getCountries()
250
+ .map((c) => ({ country: c, callingCode: `+${meta.getCountryCallingCode(c)}`, flag: flagOf(c) }))
251
+ .sort((a, b) => a.country.localeCompare(b.country));
252
+ }, [meta]);
253
+
254
+ /**
255
+ * The placeholder is the real national format for the chosen country, which
256
+ * teaches the shape better than any sentence under the field: GB shows
257
+ * `07700 900123`, US shows `(201) 555-0123`.
258
+ */
259
+ const placeholder = useMemo(() => {
260
+ if (!meta || !country) return "";
261
+ const example = meta.getExampleNumber(country, meta.examples);
262
+ return example ? example.formatNational() : "";
263
+ }, [meta, country]);
264
+
265
+ return {
266
+ /** Loaded-metadata flag. Until true, validation holds off and typing still works. */
267
+ isReady: !!meta,
268
+ country,
269
+ setCountry,
270
+ countries,
271
+ callingCode: meta && country ? `+${meta.getCountryCallingCode(country)}` : "",
272
+ /** What the visitor sees: national format, no country code. */
273
+ value: nationalValue,
274
+ setValue,
275
+ placeholder,
276
+ /** What to submit. Null until the number is valid. */
277
+ e164,
278
+ isValid: !!e164,
279
+ numberType,
280
+ /** "landline" when messaging a number that cannot receive SMS. Never blocks. */
281
+ warning,
282
+ /** "required" | "no_country" | "invalid", or null. Only after blur. */
283
+ error,
284
+ touched,
285
+ markTouched: useCallback(() => setTouched(true), []),
286
+ reset: useCallback(() => {
287
+ setNationalValue("");
288
+ setTouched(false);
289
+ setCountryState(defaultCountry);
290
+ }, [defaultCountry]),
291
+ };
292
+ }
293
+
294
+ export type UsePhoneInputReturn = ReturnType<typeof usePhoneInput>;
@@ -111,7 +111,7 @@ function buildModel(form?: FormData) {
111
111
 
112
112
  /**
113
113
  * Headless multi-section form flow: a paginated wizard over `usePublicForm` that
114
- * mirrors the server walk (docs/forms-sections.md). Handles current-section
114
+ * mirrors the server walk (docs/archive/shipped/forms-sections.md). Handles current-section
115
115
  * state, in-page field visibility (show/hide), per-section required validation,
116
116
  * section→section branching (incl. submit), Back navigation, and submit. Bring
117
117
  * your own markup (or use the styled `<SectionedFormRenderer>`).
@@ -135,7 +135,7 @@ export function useSectionedForm(formId?: string) {
135
135
  const [error, setError] = useState("");
136
136
  const [isCompleted, setIsCompleted] = useState(false);
137
137
  // Graded quiz result (null until submit + only for quiz-mode forms). Shaped by
138
- // the server's reveal policy — see docs/quizzes-certification-initiative.md.
138
+ // the server's reveal policy — see docs/archive/shipped/quizzes-certification-initiative.md.
139
139
  const [quizResult, setQuizResult] = useState<Record<string, unknown> | null>(null);
140
140
 
141
141
  // Start at the first section once the form loads.
@@ -7,6 +7,14 @@ export { MembershipGate, type MembershipGateProps, type MembershipGateState } fr
7
7
  // S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
8
8
  export { useMembershipGateNotice, type UseMembershipGateNoticeInput } from "./membership/useMembershipGateNotice";
9
9
  export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
10
+ export {
11
+ usePhoneInput,
12
+ preloadPhoneMetadata,
13
+ type UsePhoneInputOptions,
14
+ type UsePhoneInputReturn,
15
+ type PhoneNumberType,
16
+ type CountryOption,
17
+ } from "./forms/usePhoneInput";
10
18
  export { useContactForm } from "./forms/useContactForm";
11
19
  export { useSectionedForm } from "./forms/useSectionedForm";
12
20
  export { usePostEngagement, type UsePostEngagementOptions } from "./engagement/usePostEngagement";
@@ -52,7 +60,23 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
52
60
 
53
61
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
54
62
  // each composed from the data hooks so a creator can rebuild any page.
55
- export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
63
+ export {
64
+ useEventCheckout,
65
+ type UseEventCheckoutOptions,
66
+ type EventCheckoutStep,
67
+ type ToggleSeatResult,
68
+ } from "./event/useEventCheckout";
69
+ // Reserved seating (wave 2): the joined seating read and the pure pieces a
70
+ // host needs to drive a picker of its own.
71
+ export {
72
+ useEventSeating,
73
+ bestAvailableSeats,
74
+ indexEventSeats,
75
+ seatLabel,
76
+ type EventSeat,
77
+ type EventSeatingState,
78
+ type UseEventSeatingOptions,
79
+ } from "./event/useEventSeating";
56
80
  // Ticket requests (curated access): the pure decisions behind the picker and
57
81
  // the post-order routing, for a host that drives the hook from its own UI.
58
82
  export {
package/src/ui/index.ts CHANGED
@@ -101,6 +101,7 @@ export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOption
101
101
  export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
102
102
  export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
103
103
  export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
104
+ export { PhoneInput, type PhoneInputProps } from "./styled/PhoneInput";
104
105
  export { BlogComments, type BlogCommentsProps } from "./styled/BlogComments";
105
106
  export { PageActions, type PageActionsProps } from "./styled/PageActions";
106
107
  export { MusicLinkPage, type MusicLinkPageProps } from "./styled/MusicLinkPage";
@@ -118,6 +119,12 @@ export { MembershipTierCallout, type MembershipTierCalloutProps } from "./styled
118
119
  export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
119
120
  export { EventsList, type EventsListProps } from "./styled/EventsList";
120
121
  export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
122
+ // Reserved seating (wave 2): the buyer's seat picker, on its own for a host
123
+ // that composes the ticket flow from `useEventCheckout`.
124
+ export { EventSeatPicker, type EventSeatPickerProps } from "./styled/seating/EventSeatPicker";
125
+ export { SeatMapCanvas, type SeatMapCanvasProps } from "./styled/seating/SeatMapCanvas";
126
+ export { SeatLegend, type SeatLegendProps, type SeatLegendTier } from "./styled/seating/SeatLegend";
127
+ export { tierColorMap, TIER_PALETTE } from "./styled/seating/paint";
121
128
  export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
122
129
  export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
123
130
  export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
@@ -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