@tribe-nest/forge 3.29.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 (51) 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/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. 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";
@@ -21,6 +21,7 @@ import {
21
21
  useCancelMembership,
22
22
  useMembershipAccess,
23
23
  useOpenBillingPortal,
24
+ useCommunitySpaces,
24
25
  useUpdateAccount,
25
26
  useChangePassword,
26
27
  useExportAccountData,
@@ -85,6 +86,17 @@ export interface AccountDashboardProps {
85
86
  * this to its router (e.g. `/i/membership`).
86
87
  */
87
88
  onNavigateMembership?: () => void;
89
+ /**
90
+ * Navigate to the community spaces, the member home's Spaces tab (host maps
91
+ * this to its router, e.g. `/i/members/spaces`).
92
+ *
93
+ * Optional, and drawn only when the signed-in member has at least one space
94
+ * they can actually open: `/public/community/spaces` returns open spaces plus
95
+ * the spaces gated to a tier they hold, so an empty list is the server saying
96
+ * there is no door here. Without that check the account page would offer a
97
+ * member a room they get bounced out of, which is worse than no link at all.
98
+ */
99
+ onNavigateCommunity?: () => void;
88
100
  /**
89
101
  * Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
90
102
  * (which honors the visitor's selected currency + tenant exchange rates), so the
@@ -150,6 +162,7 @@ interface TabContext {
150
162
  formatDate: (value: string | number | Date) => string;
151
163
  currency?: string;
152
164
  onNavigateMembership?: () => void;
165
+ onNavigateCommunity?: () => void;
153
166
  }
154
167
 
155
168
  // ---- page -------------------------------------------------------------------
@@ -163,6 +176,7 @@ export function AccountDashboard({
163
176
  tab,
164
177
  onTabChange,
165
178
  onNavigateMembership,
179
+ onNavigateCommunity,
166
180
  formatAmount,
167
181
  formatDate,
168
182
  currency,
@@ -184,6 +198,7 @@ export function AccountDashboard({
184
198
  // settlement currency (single source), unless the host overrides it.
185
199
  currency: currency ?? siteConfig?.currency,
186
200
  onNavigateMembership,
201
+ onNavigateCommunity,
187
202
  };
188
203
 
189
204
  if (!user) return <Loading fullPage />;
@@ -244,9 +259,23 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
244
259
  const { user } = usePublicAuth();
245
260
  const { t, card, button, ghostButton } = useCardStyles();
246
261
  const tr = useForgeT();
247
- const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
262
+ const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
248
263
  const cancelMembership = useCancelMembership();
264
+ // The way into the community, which the account page had no link to at all.
265
+ // The list is already access-filtered by the server (open spaces plus the
266
+ // ones gated to a tier this member holds), so its emptiness is the whole
267
+ // gate: no spaces, no link, and nobody is sent at a locked door.
268
+ const { data: communitySpaces } = useCommunitySpaces();
269
+ const canOpenCommunity = !!onNavigateCommunity && (communitySpaces?.length ?? 0) > 0;
249
270
  const [isCancelling, setIsCancelling] = useState(false);
271
+ /**
272
+ * Cancelling is destructive and irreversible from here, so it asks first.
273
+ * CLAUDE.md puts a destructive confirmation in the category that must stay
274
+ * VISIBLE rather than behind a `?`, and until now one click on Cancel simply
275
+ * ended the membership. The client app has always asked (`EditorModal` in
276
+ * `MembershipTab.tsx`); this surface was ported without it.
277
+ */
278
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
250
279
  const [cancelError, setCancelError] = useState<string | null>(null);
251
280
 
252
281
  const membership = user?.membership;
@@ -348,7 +377,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
348
377
  <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
349
378
  )}
350
379
 
351
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
380
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
381
+ {/* Kept hard left, apart from the billing actions: this is the door to
382
+ what the membership is FOR, not another thing to do to the plan. */}
383
+ {canOpenCommunity && (
384
+ <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
385
+ {tr("forge.account_dashboard.community_spaces")}
386
+ </button>
387
+ )}
352
388
  {message.showUpdatePayment && (
353
389
  <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
354
390
  {openBillingPortal.isPending
@@ -361,7 +397,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
361
397
  but not leave. Hidden once it is already scheduled ("Ending"), where
362
398
  the only honest label would be "Cancel again". */}
363
399
  {access.hasAccess && !access.cancelAtPeriodEnd && (
364
- <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
400
+ <button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
365
401
  {isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
366
402
  </button>
367
403
  )}
@@ -371,6 +407,41 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
371
407
  : tr("forge.account_dashboard.membership_join")}
372
408
  </button>
373
409
  </div>
410
+
411
+ {/* Says WHICH of the two cancellations this is. A paid membership runs to
412
+ the end of the period already paid for; a free one stops now. A flat
413
+ "are you sure" tells a member who paid this month that they have lost
414
+ the rest of it, which is not true and reads as a threat. */}
415
+ {confirmingCancel && (
416
+ <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
417
+ <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
418
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
419
+ {membership?.subscriptionAmount
420
+ ? membership.endDate
421
+ ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
422
+ : tr("forge.account_dashboard.cancel_confirm_paid")
423
+ : tr("forge.account_dashboard.cancel_confirm_free")}
424
+ </p>
425
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
426
+ <button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
427
+ {tr("forge.account_dashboard.cancel_confirm_keep")}
428
+ </button>
429
+ <button
430
+ onClick={() => {
431
+ // Closed FIRST, so the dialog cannot be double-submitted while
432
+ // the request is in flight. The row behind it already renders
433
+ // "Cancelling" from `isCancelling`.
434
+ setConfirmingCancel(false);
435
+ void onCancel();
436
+ }}
437
+ disabled={isCancelling}
438
+ style={button}
439
+ >
440
+ {tr("forge.account_dashboard.cancel_confirm_confirm")}
441
+ </button>
442
+ </div>
443
+ </div>
444
+ )}
374
445
  </div>
375
446
  );
376
447
  }
@@ -858,6 +929,24 @@ function BookingLocationLine({
858
929
  );
859
930
  }
860
931
 
932
+ /**
933
+ * A call on the platform's own media network. A LINE, never the call itself.
934
+ *
935
+ * There is no URL to render and there never will be: entry is a ticket minted
936
+ * per attempt and valid for minutes. Mounting the room here is also the wrong
937
+ * place mechanically - the call UI lives behind `@tribe-nest/forge/media`,
938
+ * whose media-client dependency is an OPTIONAL peer, and importing it from
939
+ * this entry point would break every site that has not installed it.
940
+ */
941
+ if (location.type === "video") {
942
+ return (
943
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
944
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
945
+ {tr("forge.account_dashboard.booking_video_call")}
946
+ </div>
947
+ );
948
+ }
949
+
861
950
  return (
862
951
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
863
952
  <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}{" "}</span>