@tribe-nest/forge 3.26.0 → 3.31.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 (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,341 @@
1
+ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from "@tribe-nest/media-client";
2
+ import {
3
+ mayPublish,
4
+ type MediaGrants,
5
+ type ParticipantKind,
6
+ type Peer,
7
+ type PublishSource,
8
+ } from "@tribe-nest/media-protocol";
9
+
10
+ /**
11
+ * The call UI's decisions, as pure functions.
12
+ *
13
+ * Everything a call screen has to work out from room state lives here rather
14
+ * than inside a component, because these are the parts that are worth being
15
+ * sure about and a component is the hardest place to be sure about anything.
16
+ * Two of them have a specific failure they exist to prevent.
17
+ *
18
+ * ## The grid must not collapse
19
+ *
20
+ * `callTiles` derives one tile per PARTICIPANT, never one tile per video
21
+ * stream. A participant with the camera off is an ordinary state, not an
22
+ * absence: they are still in the room, still speaking, still someone the other
23
+ * side has to be able to see listed. The commonest bug in this kind of
24
+ * component is a grid built by mapping over video tracks, which quietly drops
25
+ * everybody who is not publishing video and re-flows the whole layout the
26
+ * moment one person turns a camera off.
27
+ *
28
+ * ## The connection state must be honest
29
+ *
30
+ * `callStatus` maps the room's own five states onto something a person can act
31
+ * on, and it deliberately keeps "reconnecting" and "failed" apart. A spinner
32
+ * that means both is a spinner that means nothing: one is worth waiting
33
+ * through, the other is worth pressing a button about, and only the UI can say
34
+ * which because only the UI is being looked at.
35
+ */
36
+
37
+ /** One rendered tile. A tile with no `videoProducerId` is NORMAL, not broken. */
38
+ export type CallTile = {
39
+ /**
40
+ * React key. Deliberately the participant plus an ORDINAL rather than the
41
+ * producer id: a camera going on and off must change what a tile shows, not
42
+ * unmount and remount the tile.
43
+ */
44
+ key: string;
45
+ identity: string;
46
+ /** What to print. The peer's name when it has one, else its identity, which
47
+ * at least tells two unnamed participants apart. */
48
+ name: string;
49
+ kind: ParticipantKind;
50
+ /** The node put one of this participant's audio producers in the active set. */
51
+ isSpeaking: boolean;
52
+ /** Attach this with `useRemoteTrack`. Absent means "no camera", not "error". */
53
+ videoProducerId?: string;
54
+ /** The publisher paused it at source. Their choice, and shown as such. */
55
+ videoPaused: boolean;
56
+ /** They are publishing audio at all. A room can be joined listen-only. */
57
+ hasAudio: boolean;
58
+ };
59
+
60
+ export type CallTileInput = {
61
+ /** Our own identity, so we are not drawn twice: self is the local preview. */
62
+ identity: string | null;
63
+ peers: readonly Peer[];
64
+ producers: readonly ProducerEntry[];
65
+ /** Producer ids, as the node sent them. Not identities. */
66
+ activeSpeakers: readonly string[];
67
+ };
68
+
69
+ const displayName = (identity: string, name?: string): string => {
70
+ const trimmed = name?.trim();
71
+ return trimmed && trimmed.length > 0 ? trimmed : identity;
72
+ };
73
+
74
+ /**
75
+ * Every participant, once, plus an extra tile for each additional video stream.
76
+ *
77
+ * A participant publishing both a camera and a screen has two video producers
78
+ * and the wire does not label which is which (`source` travels in the
79
+ * publisher's `appData`, and the node does not echo it). So the second stream
80
+ * gets its own tile under the same name rather than being guessed at, and
81
+ * neither one replaces the other: a shared screen must never take the place of
82
+ * the face that is talking over it.
83
+ *
84
+ * ## The recorder is not a participant
85
+ *
86
+ * A recording joins the room as a real peer with `kind: "egress"`, so a grid
87
+ * that maps over `peers` draws a third tile called something like `egress-9f2c`
88
+ * in a 1:1 session and the head count says three. That happens at exactly the
89
+ * moment a coach is most likely to be reading the head count, and the thing it
90
+ * would be telling them (somebody is recording) is already said properly by
91
+ * `<RecordingIndicator>`, in words, as content. So `egress` is the one kind
92
+ * that gets no tile. Every other kind is somebody or something a person is
93
+ * actually talking to - a dial-in caller, an agent - and belongs on screen.
94
+ */
95
+ export function callTiles(input: CallTileInput): readonly CallTile[] {
96
+ const speaking = new Set(input.activeSpeakers);
97
+ const tiles: CallTile[] = [];
98
+ const recorders = new Set(input.peers.filter((p) => p.kind === "egress").map((p) => p.identity));
99
+
100
+ const push = (identity: string, name: string, kind: ParticipantKind) => {
101
+ const mine = input.producers.filter((p) => p.identity === identity);
102
+ const videos = mine.filter((p) => p.kind === "video");
103
+ const hasAudio = mine.some((p) => p.kind === "audio");
104
+ const isSpeaking = mine.some((p) => p.kind === "audio" && speaking.has(p.producerId));
105
+
106
+ if (videos.length === 0) {
107
+ tiles.push({ key: `${identity}#0`, identity, name, kind, isSpeaking, videoPaused: false, hasAudio });
108
+ return;
109
+ }
110
+ videos.forEach((video, index) => {
111
+ tiles.push({
112
+ key: `${identity}#${index}`,
113
+ identity,
114
+ name,
115
+ kind,
116
+ isSpeaking,
117
+ videoProducerId: video.producerId,
118
+ videoPaused: video.paused,
119
+ hasAudio,
120
+ });
121
+ });
122
+ };
123
+
124
+ const seen = new Set<string>();
125
+ for (const peer of input.peers) {
126
+ if (peer.identity === input.identity) continue;
127
+ if (peer.kind === "egress") continue;
128
+ if (seen.has(peer.identity)) continue;
129
+ seen.add(peer.identity);
130
+ push(peer.identity, displayName(peer.identity, peer.name), peer.kind);
131
+ }
132
+
133
+ /**
134
+ * Producers whose peer we have not been told about yet.
135
+ *
136
+ * `producerAppeared` and `peerJoined` are separate frames with no ordering
137
+ * guarantee, so for a frame or two a stream can exist with nobody to hang it
138
+ * on. Dropping it would blank a tile that has media in it; drawing it under
139
+ * the identity is honest and self-corrects on the next frame.
140
+ */
141
+ for (const producer of input.producers) {
142
+ if (producer.identity === input.identity) continue;
143
+ if (recorders.has(producer.identity)) continue;
144
+ if (seen.has(producer.identity)) continue;
145
+ seen.add(producer.identity);
146
+ push(producer.identity, producer.identity, "human");
147
+ }
148
+
149
+ return tiles;
150
+ }
151
+
152
+ /**
153
+ * How many people are in this call, counting the viewer.
154
+ *
155
+ * Derived from the tiles' IDENTITIES rather than from how many tiles there are,
156
+ * because those are different numbers by design: `callTiles` gives a screen
157
+ * sharer two tiles, one for their face and one for what they are presenting.
158
+ * Counting tiles therefore announced a third person in the room the instant a
159
+ * coach shared their screen, which is the one moment a two-person call is
160
+ * certain to have a second video stream.
161
+ *
162
+ * This number is the one thing on the screen a person uses to answer "has my
163
+ * client actually arrived", so it counts people or it should not be shown.
164
+ */
165
+ export function callHeadCount(tiles: readonly CallTile[]): number {
166
+ // +1 for the viewer, who is the local preview and never has a tile here.
167
+ return new Set(tiles.map((tile) => tile.identity)).size + 1;
168
+ }
169
+
170
+ /** Remote audio has to be attached to an element or the call is silent. */
171
+ export function remoteAudioProducerIds(input: {
172
+ identity: string | null;
173
+ producers: readonly ProducerEntry[];
174
+ }): readonly string[] {
175
+ return input.producers.filter((p) => p.kind === "audio" && p.identity !== input.identity).map((p) => p.producerId);
176
+ }
177
+
178
+ export type CallStatusTone = "connecting" | "live" | "reconnecting" | "ended" | "failed";
179
+
180
+ export type CallStatus = {
181
+ tone: CallStatusTone;
182
+ /** Always shown. Connection state is CONTENT, never a help hint. */
183
+ headline: string;
184
+ /** The server's own words when it has any. Shown verbatim rather than
185
+ * translated into a friendlier lie. */
186
+ detail?: string;
187
+ /** Is pressing something the right response? Only when waiting will not fix it. */
188
+ canRetry: boolean;
189
+ };
190
+
191
+ export type CallStatusInput = {
192
+ connectionState: ConnectionState;
193
+ error: DisconnectCause | undefined;
194
+ /** The ROOM's own phase. `closed` means the node said so, on a live socket. */
195
+ phase?: RoomState["phase"];
196
+ closedReason?: string | null;
197
+ /**
198
+ * Has the SDK actually booked another attempt?
199
+ *
200
+ * `connectionState` is `reconnecting` both while one is in flight and after
201
+ * the policy has given up, so without this the screen promises a recovery
202
+ * that may not be happening. Defaults to false, which is the safe reading: it
203
+ * offers a control rather than a spinner.
204
+ */
205
+ recovering?: boolean;
206
+ };
207
+
208
+ /**
209
+ * The room's states, told honestly.
210
+ *
211
+ * `reconnecting` is the room's state for BOTH "the socket dropped and we are
212
+ * coming back" and "the node refused us", because the signal client reports one
213
+ * close path. They are completely different things to a person sitting in front
214
+ * of it, and the cause is what separates them, so the cause is read here rather
215
+ * than being flattened into a spinner.
216
+ *
217
+ * `phase` is read as well as `connectionState`, and it is not redundant: a
218
+ * `roomClosed` frame arrives on a socket that is still open, so the connection
219
+ * is "connected" for as long as it takes the node to hang up. Reading only the
220
+ * connection would show "Connected" over a call that has ended, which is the
221
+ * one thing on this list a person would act on wrongly.
222
+ *
223
+ * `recovering` is read for the same reason and it is the other half of being
224
+ * honest. "Moving you to another server" and "Connection lost, reconnecting"
225
+ * are both PROMISES, and a promise only one side of the SDK is keeping is a
226
+ * spinner over a call that is finished: the person waits, because the screen
227
+ * told them to, and the way back into a paid hour turns out to be working out
228
+ * for themselves that they should reload the page. When nothing is coming, the
229
+ * words change and a control appears.
230
+ */
231
+ export function callStatus(input: CallStatusInput): CallStatus {
232
+ const { connectionState, error } = input;
233
+ const recovering = input.recovering ?? false;
234
+
235
+ if (input.phase === "closed") {
236
+ return {
237
+ tone: "ended",
238
+ headline: "The call has ended",
239
+ ...(input.closedReason ? { detail: input.closedReason } : {}),
240
+ canRetry: false,
241
+ };
242
+ }
243
+
244
+ if (connectionState === "connected") return { tone: "live", headline: "Connected", canRetry: false };
245
+
246
+ if (connectionState === "idle" || connectionState === "connecting") {
247
+ return { tone: "connecting", headline: "Connecting to the call", canRetry: false };
248
+ }
249
+
250
+ if (connectionState === "closed") {
251
+ if (error?.type === "room_closed") {
252
+ return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
253
+ }
254
+ return { tone: "ended", headline: "You have left the call", canRetry: false };
255
+ }
256
+
257
+ // Everything below is `reconnecting`, where the cause is the whole story.
258
+ switch (error?.type) {
259
+ case "refused":
260
+ return {
261
+ tone: "failed",
262
+ headline: "Could not join this call",
263
+ detail: error.message ?? error.code,
264
+ canRetry: true,
265
+ };
266
+ case "room_closed":
267
+ return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
268
+ case "draining":
269
+ // A drain is the node asking to be left, so the move is the SDK's job and
270
+ // a button would only get in its way - as long as the move is actually
271
+ // happening. When it is not, this is the worst screen on the list: an
272
+ // ordinary deploy would otherwise end every call on that node under a
273
+ // spinner saying nobody was dropped.
274
+ return recovering
275
+ ? {
276
+ tone: "reconnecting",
277
+ headline: "Moving you to another server",
278
+ detail: "This takes a few seconds and nobody else is dropped.",
279
+ canRetry: false,
280
+ }
281
+ : {
282
+ tone: "failed",
283
+ headline: "Could not move you to another server",
284
+ detail: "Nobody else was dropped. Try again to rejoin.",
285
+ canRetry: true,
286
+ };
287
+ case "closed_by_client":
288
+ return { tone: "ended", headline: "You have left the call", canRetry: false };
289
+ default:
290
+ return recovering
291
+ ? {
292
+ tone: "reconnecting",
293
+ headline: "Connection lost, reconnecting",
294
+ detail: "Everyone else is still in the call.",
295
+ canRetry: true,
296
+ }
297
+ : {
298
+ tone: "failed",
299
+ headline: "Connection lost",
300
+ detail: "Everyone else is still in the call. Try again to rejoin.",
301
+ canRetry: true,
302
+ };
303
+ }
304
+ }
305
+
306
+ /**
307
+ * What a control publishes, in the vocabulary the SDK sends.
308
+ *
309
+ * The SOURCE, not the grant's kind, and the difference is the whole point:
310
+ * `room.publish(track, source)` puts one of these three words on the wire and
311
+ * the node decides against it. A control asking about "audio" would be asking
312
+ * a question no publish ever asks.
313
+ *
314
+ * Re-exported from the protocol rather than restated, because a second list of
315
+ * the same three words is a second list that can drift from the one the node
316
+ * reads.
317
+ */
318
+ export type { PublishSource };
319
+
320
+ /**
321
+ * Should this control be drawn at all?
322
+ *
323
+ * A UI hint and nothing more. The node checks the same grant on the publish
324
+ * itself and its answer is the one that counts, so hiding a button here is
325
+ * about not offering something that will be refused, never about enforcing
326
+ * anything. `publishKinds` absent means every kind, which is what the protocol
327
+ * says and what the node does.
328
+ *
329
+ * `mayPublish` is the protocol's own function, the same one the node calls, and
330
+ * that is deliberate rather than tidy. A grant is written in kinds ("audio",
331
+ * "video", "screen") and a publish declares a source ("microphone", "camera",
332
+ * "screen"), so a second copy of the translation living here is a second copy
333
+ * that can disagree with the node - and a button offered for a publish that is
334
+ * about to be refused is worse than no button at all.
335
+ *
336
+ * The track kind is implied by the source, because these three are the only
337
+ * sources this UI has: a microphone is audio and the other two are video.
338
+ */
339
+ export function canPublishSource(grants: MediaGrants | undefined, source: PublishSource): boolean {
340
+ return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
341
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Forge UI - live calls.
3
+ *
4
+ * ## Where the ONE call UI lives
5
+ *
6
+ * Here. `CallStage` is built once in this package, over the hooks in
7
+ * `@tribe-nest/media-client/react`, and BOTH rendering surfaces render that one
8
+ * component: code websites import it from `@tribe-nest/forge/media`, and admin
9
+ * imports the same export and feeds it the dashboard's own theme tokens through
10
+ * `<ForgeThemeProvider>`. A second implementation would mean every fix to the
11
+ * ordering rules - device before transport, attach before resume, follow the
12
+ * active set - had to be made twice, and the second copy would be the one that
13
+ * drifted.
14
+ *
15
+ * The HOOKS are still pure re-exports, because the room logic belongs to the
16
+ * SDK and Forge has no business owning a second copy of it.
17
+ *
18
+ * ## Why a barrel at all, rather than telling sites to import the SDK
19
+ *
20
+ * The code agent builds sites from Forge's manifest, which is generated from
21
+ * these barrels. An export that is not in one does not exist as far as the
22
+ * agent is concerned, however well documented it is elsewhere. `./media` is
23
+ * registered in `ENTRY_SUBPATHS` in `apps/backend/src/utils/forgeManifest.ts`
24
+ * for exactly that reason.
25
+ *
26
+ * ## What a site actually needs to do
27
+ *
28
+ * Wrap the call in `<MediaRoomProvider>` and give it a `getCredentials`
29
+ * callback that fetches a fresh join ticket. It is a CALLBACK rather than a
30
+ * token because a ticket expires in minutes and a call lasts an hour: a token
31
+ * passed once means the first reconnect presents an expired one.
32
+ *
33
+ * ```tsx
34
+ * <MediaRoomProvider getCredentials={() => fetchJoinTicket(bookingId)}>
35
+ * <CallStage />
36
+ * </MediaRoomProvider>
37
+ * ```
38
+ *
39
+ * Inside, `useParticipants()`, `useRemoteTrack(producerId)` and
40
+ * `useLocalMedia()` are the whole surface. Nothing here decides what a
41
+ * participant is allowed to do: `useGrants()` is for RENDERING - so a button
42
+ * that the node would refuse is not offered - and the node enforces
43
+ * independently. A client-side grant check is a UI hint, never the decision.
44
+ */
45
+ export {
46
+ MediaRoomProvider,
47
+ useActiveSpeakers,
48
+ useGrants,
49
+ useLocalMedia,
50
+ useLocalPublications,
51
+ useMediaRoom,
52
+ useParticipants,
53
+ useRecording,
54
+ useRemoteTrack,
55
+ useRoomState,
56
+ // The producers a UI should RENDER: `useRoomState().producers` is the node's
57
+ // full announcement history and deliberately keeps producers the current
58
+ // subscribe rule bars, so that a barrier which narrows can later widen.
59
+ useVisibleProducers,
60
+ type LocalMediaControls,
61
+ type LocalSource,
62
+ type MediaRoomProviderProps,
63
+ } from "@tribe-nest/media-client/react";
64
+
65
+ export type {
66
+ ConnectionState,
67
+ DisconnectCause,
68
+ LocalPublication,
69
+ MediaRoom,
70
+ MediaTrack,
71
+ // `CallTileInput` is built from these, so a site laying out its own grid over
72
+ // `callTiles` needs them by name.
73
+ ProducerEntry,
74
+ RoomState,
75
+ } from "@tribe-nest/media-client";
76
+
77
+ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protocol";
78
+
79
+ /**
80
+ * The screen itself, and the decisions behind it.
81
+ *
82
+ * `CallStage` is the whole call: participant tiles with names, the local
83
+ * preview, mic / camera / screenshare toggles, an active-speaker indication, a
84
+ * leave control and an honest connection state. Mount it inside a
85
+ * `<MediaRoomProvider>` (or `<BookingCallProvider>`) and behind a Join control,
86
+ * because the provider connects on mount.
87
+ *
88
+ * `callTiles`, `callStatus` and `canPublishSource` are exported alongside it for
89
+ * a surface that wants a different layout over the same decisions. They are
90
+ * pure and they are where the two failures worth being sure about live: a grid
91
+ * that keeps a tile for somebody with the camera off, and a connection state
92
+ * that separates "reconnecting" from "refused" rather than showing one spinner
93
+ * for both.
94
+ */
95
+ export { CallStage, type CallStageProps } from "./CallStage";
96
+ export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
97
+ export {
98
+ callHeadCount,
99
+ callStatus,
100
+ callTiles,
101
+ canPublishSource,
102
+ remoteAudioProducerIds,
103
+ type CallStatus,
104
+ type CallStatusInput,
105
+ type CallStatusTone,
106
+ type CallTile,
107
+ type CallTileInput,
108
+ type PublishSource,
109
+ } from "./callState";
110
+
111
+ /**
112
+ * Coaching sessions held on the platform's own media network.
113
+ *
114
+ * These are the ONE thing in this barrel that is not a re-export, because they
115
+ * are the only part that knows about a TribeNest endpoint: `BookingCallProvider`
116
+ * is `MediaRoomProvider` already wired to the booking credential endpoint, so a
117
+ * site never has to know what a join ticket is or how often to fetch one.
118
+ *
119
+ * They live in THIS barrel rather than in `./ui` for a mechanical reason: the
120
+ * code agent builds sites from Forge's manifest, and the manifest is generated
121
+ * from the registered entry points. An export outside one of them does not
122
+ * exist as far as the agent is concerned, however well documented it is.
123
+ */
124
+ export {
125
+ BOOKING_CALL_CLOSES_MINUTES_AFTER,
126
+ BOOKING_CALL_OPENS_MINUTES_BEFORE,
127
+ BookingCallProvider,
128
+ bookingCallWindow,
129
+ useBookingSessionCredentials,
130
+ type BookingCallProviderProps,
131
+ type BookingCallSubject,
132
+ type BookingCallWindow,
133
+ type BookingSessionCredentials,
134
+ type BookingSessionRole,
135
+ } from "./bookingSession";
@@ -8,6 +8,7 @@ import { CookieConsent } from "../styled/CookieConsent";
8
8
  import { AiAgentWidget } from "../styled/AiAgentWidget";
9
9
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
10
10
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
11
+ import { ForgeI18nProvider } from "../../i18n";
11
12
  import { PoweredBy } from "./PoweredBy";
12
13
  import { PreviewDiagnostics } from "./PreviewDiagnostics";
13
14
  import { shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
@@ -142,33 +143,41 @@ export function TribeNestApp({
142
143
  : null;
143
144
  return (
144
145
  <ForgeProvider editable={editable} {...forgeProps}>
145
- {/* First in the tree: it must render even if everything below it doesn't. */}
146
- {ssrFailure && <PreviewDiagnostics diagnostics={ssrFailure} />}
147
- {children}
148
- {/* "Powered by TribeNest" last in the page flow, so it sits below the
149
- tenant's own footer. Released builds only (live + published). */}
150
- <PoweredByGate editable={editable} state={state} />
151
- {/* Global playback bar — self-pins to the viewport bottom, renders nothing
152
- until a track loads. */}
153
- <AudioPlayer />
154
- {/* Auto page views + clicks (+ Meta Pixel, gated on marketing consent).
155
- First-party runs cookieless. Off in the editor so preview isn't counted. */}
156
- {analytics && <ForgeAnalytics enabled={!editable} firstPartyMode="cookieless" />}
157
- {/* Ad attribution capture (tracked-link ref + first-touch landing + visit
158
- beacon). Off in the editor so preview visits aren't credited. */}
159
- <AttributionCapture enabled={!editable} apiUrl={forgeProps.apiUrl} profileId={forgeProps.profileId} />
160
- {/* PWA — SW registration + install banner + (fallback) manifest link. */}
161
- {pwa && <PwaRegistration enabled={pwaEnabled} />}
162
- {pwa && <InstallBanner enabled={pwaEnabled} />}
163
- {pwa && <PwaHeadClient enabled={pwaEnabled} manifestHref={manifestHref} />}
164
- {/* Consent banner shown on first visit, re-openable from anywhere via
165
- useCookieConsent().reopen(). Hidden in the editor. */}
166
- {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
167
- {/* Website AI agent floating bubble, self-hiding when the profile has no
168
- agent enabled. Lives here rather than in each __root so enabling the
169
- agent in admin is enough to make it appear; before this it shipped only
170
- on the Craft stack and no code site ever rendered it. */}
171
- {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
146
+ {/* Forge's own copy, in the site's language. Mounted here rather than in
147
+ each tenant's __root.tsx, so a Forge upgrade is enough to give an
148
+ existing site i18n. `locale` is the ForgeProvider prop the site
149
+ already passes (absent or unsupported resolves to English). When
150
+ `profile_configurations.locale` lands on SiteConfig, that becomes the
151
+ source and this prop stays the override. */}
152
+ <ForgeI18nProvider locale={forgeProps.locale}>
153
+ {/* First in the tree: it must render even if everything below it doesn't. */}
154
+ {ssrFailure && <PreviewDiagnostics diagnostics={ssrFailure} />}
155
+ {children}
156
+ {/* "Powered by TribeNest" — last in the page flow, so it sits below the
157
+ tenant's own footer. Released builds only (live + published). */}
158
+ <PoweredByGate editable={editable} state={state} />
159
+ {/* Global playback bar self-pins to the viewport bottom, renders nothing
160
+ until a track loads. */}
161
+ <AudioPlayer />
162
+ {/* Auto page views + clicks (+ Meta Pixel, gated on marketing consent).
163
+ First-party runs cookieless. Off in the editor so preview isn't counted. */}
164
+ {analytics && <ForgeAnalytics enabled={!editable} firstPartyMode="cookieless" />}
165
+ {/* Ad attribution capture (tracked-link ref + first-touch landing + visit
166
+ beacon). Off in the editor so preview visits aren't credited. */}
167
+ <AttributionCapture enabled={!editable} apiUrl={forgeProps.apiUrl} profileId={forgeProps.profileId} />
168
+ {/* PWA SW registration + install banner + (fallback) manifest link. */}
169
+ {pwa && <PwaRegistration enabled={pwaEnabled} />}
170
+ {pwa && <InstallBanner enabled={pwaEnabled} />}
171
+ {pwa && <PwaHeadClient enabled={pwaEnabled} manifestHref={manifestHref} />}
172
+ {/* Consent banner shown on first visit, re-openable from anywhere via
173
+ useCookieConsent().reopen(). Hidden in the editor. */}
174
+ {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
175
+ {/* Website AI agent — floating bubble, self-hiding when the profile has no
176
+ agent enabled. Lives here rather than in each __root so enabling the
177
+ agent in admin is enough to make it appear; before this it shipped only
178
+ on the Craft stack and no code site ever rendered it. */}
179
+ {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
180
+ </ForgeI18nProvider>
172
181
  </ForgeProvider>
173
182
  );
174
183
  }