@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.
- package/package.json +7 -2
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +40 -2
- package/src/i18n/en.json +40 -2
- package/src/ui/index.ts +25 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +153 -62
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/styled/AccountDashboard.tsx +106 -6
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -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
|
-
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
239
|
-
...(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")
|
|
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",
|
|
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",
|
|
271
|
+
return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
|
|
253
272
|
}
|
|
254
|
-
return { tone: "ended",
|
|
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
|
-
|
|
263
|
-
|
|
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",
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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",
|
|
307
|
+
return { tone: "ended", headlineKey: LEFT, canRetry: false };
|
|
289
308
|
default:
|
|
290
309
|
return recovering
|
|
291
310
|
? {
|
|
292
311
|
tone: "reconnecting",
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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,
|
|
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
|
|
937
|
-
*
|
|
938
|
-
*
|
|
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
|
+
});
|