@tribe-nest/forge 3.31.0 → 3.34.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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * When a coaching session's video room is open, computed WITHOUT the media SDK.
3
+ *
4
+ * This module deliberately imports nothing from `@tribe-nest/media-client`.
5
+ * `AccountDashboard` lives in the `./ui` entry that every code website loads,
6
+ * and it needs to know whether to draw a Join control long before anybody
7
+ * presses it. Keeping the window pure means that decision costs the site
8
+ * nothing; the call UI itself is loaded on the click (see `BookingCallScreen`).
9
+ * `@tribe-nest/forge/media` re-exports everything here, so a site that already
10
+ * imports from that barrel sees no difference.
11
+ */
12
+
13
+ /**
14
+ * How early the room opens, and how long it stays open.
15
+ *
16
+ * A MIRROR of the server's `SESSION_JOIN_OPENS_MINUTES_BEFORE` and
17
+ * `SESSION_JOIN_CLOSES_MINUTES_AFTER` (`apps/backend/src/services/public/
18
+ * coaching/commands/bookingSession.ts`), and a mirror is the most this side
19
+ * can hold. The server's own `roomOpensAt` / `roomClosesAt` arrive only with a
20
+ * join ticket, and a ticket is only minted INSIDE the window, so the one moment
21
+ * a site needs "opens at" copy (before the window) is the one moment the
22
+ * server's timestamps are unreachable. These constants exist for that pre-join
23
+ * draw and nothing else: the decision is the server's, its refusal names the
24
+ * real window, and `<CallStage>` shows that refusal verbatim. Change the server
25
+ * and this pair together.
26
+ */
27
+ export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
28
+ export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
29
+
30
+ const MINUTE = 60_000;
31
+
32
+ /** What a booking has to carry for the window to be computable. */
33
+ export type BookingCallSubject = {
34
+ status: string;
35
+ sessionStartTime: string;
36
+ sessionEndTime: string;
37
+ location?: { type: string } | null;
38
+ };
39
+
40
+ export type BookingCallWindow = {
41
+ /** Does this session happen on a platform video call at all? */
42
+ isVideo: boolean;
43
+ /** May a ticket be minted right now? Draw the Join control from this. */
44
+ isOpen: boolean;
45
+ /** Before the window, so the answer is "come back at". */
46
+ opensAt: Date;
47
+ /** After it the server refuses new tickets. */
48
+ closesAt: Date;
49
+ };
50
+
51
+ /**
52
+ * Is this session's room open, and when does it open?
53
+ *
54
+ * For DRAWING the control and nothing else. The server applies the same window
55
+ * and its refusal is the decision: a clock that is ten minutes fast must not be
56
+ * able to talk itself into a room, and a clock that is ten minutes slow must
57
+ * not hide a session that has already started. Show the server's refusal
58
+ * verbatim when a join fails rather than pre-judging it here.
59
+ *
60
+ * `now` is a parameter so a caller can drive it from a ticking value and get a
61
+ * re-render at the moment the window opens; a hook reading the clock itself
62
+ * would be stale until something else happened to re-render.
63
+ */
64
+ export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
65
+ const startsAt = new Date(booking.sessionStartTime);
66
+ const endsAt = new Date(booking.sessionEndTime);
67
+ const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
68
+ const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
69
+
70
+ // A cancelled session has no room to enter, which is also why the server
71
+ // withholds `location` for anything that is not confirmed - so this reads
72
+ // `isVideo: false` for one, rather than offering a button that 409s.
73
+ const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
74
+
75
+ return {
76
+ isVideo,
77
+ isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
78
+ opensAt,
79
+ closesAt,
80
+ };
81
+ }
@@ -177,13 +177,27 @@ export function remoteAudioProducerIds(input: {
177
177
 
178
178
  export type CallStatusTone = "connecting" | "live" | "reconnecting" | "ended" | "failed";
179
179
 
180
+ /**
181
+ * The connection, as KEYS into Forge's bundles.
182
+ *
183
+ * `callStatus` is pure and has no locale, so it names copy rather than
184
+ * carrying it: `CallStage` puts the keys through `useForgeT()`, and a site
185
+ * laying out its own status line does the same. Returning English sentences
186
+ * from here was how the whole call screen shipped in one language on sites
187
+ * whose every other word was German.
188
+ *
189
+ * `detailText` is the exception, on purpose: it is the SERVER'S own sentence
190
+ * (a refusal message, a close reason) and is shown verbatim, never translated
191
+ * into a friendlier lie.
192
+ */
180
193
  export type CallStatus = {
181
194
  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;
195
+ /** Always shown. Connection state is CONTENT, never a help hint. `forge.call_stage.*`. */
196
+ headlineKey: string;
197
+ /** Our own second line, when there is one. `forge.call_stage.*`. */
198
+ detailKey?: string;
199
+ /** The server's own words when it has any. Shown verbatim. */
200
+ detailText?: string;
187
201
  /** Is pressing something the right response? Only when waiting will not fix it. */
188
202
  canRetry: boolean;
189
203
  };
@@ -205,6 +219,9 @@ export type CallStatusInput = {
205
219
  recovering?: boolean;
206
220
  };
207
221
 
222
+ const ENDED = "forge.call_stage.status_ended";
223
+ const LEFT = "forge.call_stage.status_left";
224
+
208
225
  /**
209
226
  * The room's states, told honestly.
210
227
  *
@@ -235,23 +252,25 @@ export function callStatus(input: CallStatusInput): CallStatus {
235
252
  if (input.phase === "closed") {
236
253
  return {
237
254
  tone: "ended",
238
- headline: "The call has ended",
239
- ...(input.closedReason ? { detail: input.closedReason } : {}),
255
+ headlineKey: ENDED,
256
+ ...(input.closedReason ? { detailText: input.closedReason } : {}),
240
257
  canRetry: false,
241
258
  };
242
259
  }
243
260
 
244
- if (connectionState === "connected") return { tone: "live", headline: "Connected", canRetry: false };
261
+ if (connectionState === "connected") {
262
+ return { tone: "live", headlineKey: "forge.call_stage.status_connected", canRetry: false };
263
+ }
245
264
 
246
265
  if (connectionState === "idle" || connectionState === "connecting") {
247
- return { tone: "connecting", headline: "Connecting to the call", canRetry: false };
266
+ return { tone: "connecting", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
248
267
  }
249
268
 
250
269
  if (connectionState === "closed") {
251
270
  if (error?.type === "room_closed") {
252
- return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
271
+ return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
253
272
  }
254
- return { tone: "ended", headline: "You have left the call", canRetry: false };
273
+ return { tone: "ended", headlineKey: LEFT, canRetry: false };
255
274
  }
256
275
 
257
276
  // Everything below is `reconnecting`, where the cause is the whole story.
@@ -259,12 +278,12 @@ export function callStatus(input: CallStatusInput): CallStatus {
259
278
  case "refused":
260
279
  return {
261
280
  tone: "failed",
262
- headline: "Could not join this call",
263
- detail: error.message ?? error.code,
281
+ headlineKey: "forge.call_stage.status_refused",
282
+ detailText: error.message ?? error.code,
264
283
  canRetry: true,
265
284
  };
266
285
  case "room_closed":
267
- return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
286
+ return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
268
287
  case "draining":
269
288
  // A drain is the node asking to be left, so the move is the SDK's job and
270
289
  // a button would only get in its way - as long as the move is actually
@@ -274,30 +293,30 @@ export function callStatus(input: CallStatusInput): CallStatus {
274
293
  return recovering
275
294
  ? {
276
295
  tone: "reconnecting",
277
- headline: "Moving you to another server",
278
- detail: "This takes a few seconds and nobody else is dropped.",
296
+ headlineKey: "forge.call_stage.status_moving",
297
+ detailKey: "forge.call_stage.status_moving_detail",
279
298
  canRetry: false,
280
299
  }
281
300
  : {
282
301
  tone: "failed",
283
- headline: "Could not move you to another server",
284
- detail: "Nobody else was dropped. Try again to rejoin.",
302
+ headlineKey: "forge.call_stage.status_move_failed",
303
+ detailKey: "forge.call_stage.status_move_failed_detail",
285
304
  canRetry: true,
286
305
  };
287
306
  case "closed_by_client":
288
- return { tone: "ended", headline: "You have left the call", canRetry: false };
307
+ return { tone: "ended", headlineKey: LEFT, canRetry: false };
289
308
  default:
290
309
  return recovering
291
310
  ? {
292
311
  tone: "reconnecting",
293
- headline: "Connection lost, reconnecting",
294
- detail: "Everyone else is still in the call.",
312
+ headlineKey: "forge.call_stage.status_reconnecting",
313
+ detailKey: "forge.call_stage.status_reconnecting_detail",
295
314
  canRetry: true,
296
315
  }
297
316
  : {
298
317
  tone: "failed",
299
- headline: "Connection lost",
300
- detail: "Everyone else is still in the call. Try again to rejoin.",
318
+ headlineKey: "forge.call_stage.status_lost",
319
+ detailKey: "forge.call_stage.status_lost_detail",
301
320
  canRetry: true,
302
321
  };
303
322
  }
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { lazy, Suspense, useEffect, useState } from "react";
2
2
  import {
3
3
  usePublicAuth,
4
4
  useUserOrders,
@@ -36,6 +36,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
36
36
  import { useSiteConfig } from "../../data/queries/useWebsite";
37
37
  import { useAmountFormatter } from "../format/useFormatCurrency";
38
38
  import { Loading } from "./Loading";
39
+ import { bookingCallWindow } from "../media/bookingWindow";
39
40
  import { formatCountdown } from "./EventWaitlist";
40
41
  import { AddToCalendar } from "./AddToCalendar";
41
42
  import { WalletPassButtons } from "./WalletPassButtons";
@@ -930,13 +931,13 @@ function BookingLocationLine({
930
931
  }
931
932
 
932
933
  /**
933
- * A call on the platform's own media network. A LINE, never the call itself.
934
+ * A call on the platform's own media network. A LINE, and the way in is the
935
+ * `BookingCallEntry` control the row renders below it.
934
936
  *
935
937
  * 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.
938
+ * per attempt and valid for minutes, so the Join control mints one on the
939
+ * click and mounts the call in place (`BookingCallEntry`, loaded lazily so a
940
+ * site pays for the SDK only when somebody presses it).
940
941
  */
941
942
  if (location.type === "video") {
942
943
  return (
@@ -1073,6 +1074,105 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1073
1074
  </div>
1074
1075
 
1075
1076
  {picking && <RescheduleSlotPicker booking={booking} onDone={() => setPicking(false)} />}
1077
+
1078
+ {/* The way INTO a video session: a Join control while the room is open,
1079
+ the call itself once pressed. Nothing for any other kind of session. */}
1080
+ {!cancelled && <BookingCallEntry booking={booking} />}
1081
+ </div>
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * The call UI, fetched on the click. `import()` splits it (and the media SDK
1087
+ * with mediasoup underneath) out of the `./ui` bundle, so a visitor who never
1088
+ * joins a call never downloads it. `bookingCallWindow` is deliberately in a
1089
+ * module with no such dependency, which is what lets the CONTROL be decided
1090
+ * here without loading the SDK.
1091
+ */
1092
+ const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
1093
+
1094
+ /**
1095
+ * The buyer's way into a coaching video call, on a code website.
1096
+ *
1097
+ * The room, the hooks, the credential endpoint and `BookingCallProvider` all
1098
+ * shipped before this control existed, and none of it was reachable from the
1099
+ * one place a client looks: the row for their session printed "Video call on
1100
+ * this site" and offered nothing to press. Admin's own help text told coaches
1101
+ * the client joins "from their account on your site", so this is where it has
1102
+ * to be. It renders the SAME `<CallStage>` the coach sees in admin.
1103
+ *
1104
+ * ## Why the call is mounted only after a click
1105
+ *
1106
+ * The provider connects on mount and closes on unmount. Rendering it for every
1107
+ * row in a list of sessions would open a media connection per row, against
1108
+ * rooms the server would then have to provision. So it lives behind Join, and
1109
+ * leaving unmounts it.
1110
+ *
1111
+ * ## Why the window ticks
1112
+ *
1113
+ * `bookingCallWindow` takes `now` as an argument rather than reading the clock,
1114
+ * so a portal left open across the opening minute has to be told. A minute is
1115
+ * fine: the server applies the same window and refuses a ticket outside it,
1116
+ * and its refusal is what `<CallStage>` shows rather than a pre-judgement here.
1117
+ */
1118
+ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1119
+ const { button } = useCardStyles();
1120
+ const tr = useForgeT();
1121
+ const [now, setNow] = useState(() => new Date());
1122
+ const [joined, setJoined] = useState(false);
1123
+
1124
+ useEffect(() => {
1125
+ const timer = setInterval(() => setNow(new Date()), 30_000);
1126
+ return () => clearInterval(timer);
1127
+ }, []);
1128
+
1129
+ const call = bookingCallWindow(booking, now);
1130
+ if (!call.isVideo) return null;
1131
+
1132
+ if (joined) {
1133
+ // The provider's lifetime is exactly the call's: unmounting this subtree is
1134
+ // what closes the socket, stops the local tracks and puts the camera light
1135
+ // out. Leaving therefore goes through state, not through a `close()` that
1136
+ // leaves a live provider mounted to reconnect on its next render.
1137
+ return (
1138
+ <div style={{ marginTop: 12 }}>
1139
+ <Suspense fallback={<Loading />}>
1140
+ <LazyBookingCallScreen
1141
+ bookingId={booking.id}
1142
+ title={booking.coachingProductTitle}
1143
+ onLeave={() => setJoined(false)}
1144
+ />
1145
+ </Suspense>
1146
+ </div>
1147
+ );
1148
+ }
1149
+
1150
+ if (!call.isOpen) {
1151
+ // State the buyer has to act on, so it is content and stays visible: the
1152
+ // room is not open yet, and this is when to come back.
1153
+ return (
1154
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1155
+ {now < call.opensAt
1156
+ ? tr("forge.account_dashboard.booking_video_opens_at", {
1157
+ datetime: call.opensAt.toLocaleString(undefined, {
1158
+ weekday: "short",
1159
+ day: "numeric",
1160
+ month: "short",
1161
+ hour: "numeric",
1162
+ minute: "2-digit",
1163
+ timeZone: booking.coachingProductTimezone ?? undefined,
1164
+ }),
1165
+ })
1166
+ : tr("forge.account_dashboard.booking_video_closed")}
1167
+ </div>
1168
+ );
1169
+ }
1170
+
1171
+ return (
1172
+ <div style={{ marginTop: 12 }}>
1173
+ <button type="button" onClick={() => setJoined(true)} style={button}>
1174
+ {tr("forge.account_dashboard.booking_join_video")}
1175
+ </button>
1076
1176
  </div>
1077
1177
  );
1078
1178
  }
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+ import type { MyBooking } from "../../../data/queries/useMyBookings";
8
+ import { AccountDashboard } from "../AccountDashboard";
9
+
10
+ /**
11
+ * The account page's way INTO a coaching video call.
12
+ *
13
+ * The room, the hooks, the credential endpoint and `BookingCallProvider` all
14
+ * shipped, and on a code website none of it was reachable: the row for a
15
+ * video session printed "Video call on this site" and offered nothing to
16
+ * press, while admin's help text told coaches the client joins "from their
17
+ * account on your site". This pins the control the row now draws.
18
+ *
19
+ * Rendered through `react-dom/server`: no DOM, no effects, so the bookings
20
+ * query is SEEDED into the react-query cache. That keeps the real
21
+ * `useMyBookings` in the path, and a rename of its query key fails here. The
22
+ * call itself is behind a click and a `React.lazy`, so nothing here ever
23
+ * touches the media SDK: the spec is about the CONTROL, and it proves the SDK
24
+ * is not needed to decide whether to draw it.
25
+ */
26
+
27
+ const PROFILE_ID = "profile-1";
28
+ const ACCOUNT_ID = "account-1";
29
+ const MINUTE = 60_000;
30
+
31
+ const member = {
32
+ id: ACCOUNT_ID,
33
+ email: "client@example.com",
34
+ firstName: "Client",
35
+ lastName: "Person",
36
+ kind: "fan",
37
+ status: "active",
38
+ createdAt: "2026-01-01T00:00:00.000Z",
39
+ updatedAt: "2026-01-01T00:00:00.000Z",
40
+ membership: null,
41
+ };
42
+
43
+ const booking = (overrides: Partial<MyBooking> = {}): MyBooking => {
44
+ const start = new Date(Date.now() + 5 * MINUTE);
45
+ const end = new Date(start.getTime() + 60 * MINUTE);
46
+ return {
47
+ id: "booking-1",
48
+ status: "confirmed",
49
+ email: "client@example.com",
50
+ firstName: "Client",
51
+ lastName: "Person",
52
+ totalAmount: 40,
53
+ totalAmountInChargedCurrency: null,
54
+ currency: "USD",
55
+ chargedCurrency: null,
56
+ refundState: null,
57
+ refundedAmountCents: null,
58
+ selfCancelledAt: null,
59
+ cancellationOutcome: null,
60
+ createdAt: "2026-01-01T00:00:00.000Z",
61
+ coachingBookingSlotId: "slot-1",
62
+ sessionStartTime: start.toISOString(),
63
+ sessionEndTime: end.toISOString(),
64
+ coachingProductId: "product-1",
65
+ coachingProductSlug: "video-session",
66
+ coachingProductTitle: "Video session",
67
+ coachingProductTimezone: null,
68
+ coachingProductDurationMinutes: 60,
69
+ cancellation: {
70
+ policy: null,
71
+ cutoffHours: null,
72
+ terms: null,
73
+ description: null,
74
+ canCancel: false,
75
+ reason: null,
76
+ deadline: null,
77
+ },
78
+ location: { type: "video" } as MyBooking["location"],
79
+ canReschedule: false,
80
+ rescheduleReason: null,
81
+ calendarUrl: null,
82
+ calendarWebcalUrl: null,
83
+ ...overrides,
84
+ };
85
+ };
86
+
87
+ function render(rows: MyBooking[]): string {
88
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
89
+ queryClient.setQueryData(["my-bookings", ACCOUNT_ID, PROFILE_ID, 1, 20], {
90
+ data: rows,
91
+ total: rows.length,
92
+ page: 1,
93
+ limit: 20,
94
+ });
95
+
96
+ return renderToStaticMarkup(
97
+ <QueryClientProvider client={queryClient}>
98
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
99
+ <PublicAuthContext.Provider
100
+ value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
101
+ >
102
+ <ForgeThemeProvider
103
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
104
+ >
105
+ <AccountDashboard tab="bookings" onNavigateMembership={() => {}} />
106
+ </ForgeThemeProvider>
107
+ </PublicAuthContext.Provider>
108
+ </ForgeClientProvider>
109
+ </QueryClientProvider>,
110
+ );
111
+ }
112
+
113
+ describe("AccountDashboard: the way into a coaching video call", () => {
114
+ it("REGRESSION: offers Join for a video session whose room is open", () => {
115
+ // Starts in 5 minutes: inside the 15-minutes-early window.
116
+ const html = render([booking()]);
117
+
118
+ expect(html).toContain("Video session");
119
+ expect(html).toContain("Join video call");
120
+ // The line still says where the session is; the control is in addition.
121
+ expect(html).toContain("Video call on this site");
122
+ });
123
+
124
+ it("says when the room opens, rather than offering a Join that would 409", () => {
125
+ const start = new Date(Date.now() + 3 * 60 * MINUTE);
126
+ const html = render([
127
+ booking({
128
+ sessionStartTime: start.toISOString(),
129
+ sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
130
+ }),
131
+ ]);
132
+
133
+ expect(html).not.toContain("Join video call");
134
+ expect(html).toContain("the room opens");
135
+ });
136
+
137
+ it("says the room has closed once the window is over", () => {
138
+ const start = new Date(Date.now() - 5 * 60 * MINUTE);
139
+ const html = render([
140
+ booking({
141
+ sessionStartTime: start.toISOString(),
142
+ sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
143
+ }),
144
+ ]);
145
+
146
+ expect(html).not.toContain("Join video call");
147
+ expect(html).toContain("room has closed");
148
+ });
149
+
150
+ it("draws nothing for a session that is not a video call", () => {
151
+ const html = render([
152
+ booking({ location: { type: "online", url: "https://meet.example.com/x" } as MyBooking["location"] }),
153
+ ]);
154
+
155
+ expect(html).not.toContain("Join video call");
156
+ expect(html).not.toContain("the room opens");
157
+ expect(html).toContain("https://meet.example.com/x");
158
+ });
159
+
160
+ it("draws nothing for a cancelled session, whose room the server withholds", () => {
161
+ const html = render([booking({ status: "canceled", selfCancelledAt: "2026-01-02T00:00:00.000Z", location: null })]);
162
+
163
+ expect(html).not.toContain("Join video call");
164
+ expect(html).not.toContain("the room opens");
165
+ });
166
+ });