@tribe-nest/forge 3.31.0 → 3.35.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 +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- 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/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { MediaRoomProvider } from "@tribe-nest/media-client/react";
|
|
4
|
-
import type
|
|
4
|
+
import { MediaError, type MediaErrorCode, type MediaGrants } from "@tribe-nest/media-protocol";
|
|
5
5
|
|
|
6
6
|
import { useForge } from "../../provider/ForgeProvider";
|
|
7
7
|
|
|
@@ -96,11 +96,42 @@ export function useBookingSessionCredentials(bookingId: string): () => Promise<B
|
|
|
96
96
|
// function on every parent render, and `<MediaRoomProvider>` holds its
|
|
97
97
|
// options in a ref precisely because that is the default mistake.
|
|
98
98
|
return useCallback(async () => {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
try {
|
|
100
|
+
const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
|
|
101
|
+
return res.data as BookingSessionCredentials;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw credentialRefusal(error);
|
|
104
|
+
}
|
|
101
105
|
}, [client, bookingId]);
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The server's refusal, in the SDK's vocabulary, so the screen shows it.
|
|
110
|
+
*
|
|
111
|
+
* The join endpoint answers a person who belongs in the call with the REAL
|
|
112
|
+
* reason when it says no: the room opens in ten minutes, the session is not
|
|
113
|
+
* confirmed, the window has closed. Left as an axios error, that sentence never
|
|
114
|
+
* reached the screen: the SDK's `causeFromError` reads a non-`MediaError` as a
|
|
115
|
+
* dropped socket ("Request failed with status code 409"), and `callStatus`
|
|
116
|
+
* then says "Connection lost" about a call the person was never in. As a
|
|
117
|
+
* `MediaError` the cause is `refused`, which the screen renders as "Could not
|
|
118
|
+
* join this call" with the server's own sentence and a Try again. That is what
|
|
119
|
+
* every docblock on this surface promised and what the wire could not deliver.
|
|
120
|
+
*
|
|
121
|
+
* Only a response with a status is translated. A network failure (no
|
|
122
|
+
* response) really is a connection problem and keeps its own reading.
|
|
123
|
+
*/
|
|
124
|
+
function credentialRefusal(error: unknown): unknown {
|
|
125
|
+
const response = (error as { response?: { status?: number; data?: { message?: unknown } } } | null)?.response;
|
|
126
|
+
const status = response?.status;
|
|
127
|
+
if (typeof status !== "number") return error;
|
|
128
|
+
|
|
129
|
+
const message = typeof response?.data?.message === "string" ? response.data.message : undefined;
|
|
130
|
+
const code: MediaErrorCode =
|
|
131
|
+
status === 401 ? "unauthorized" : status === 404 ? "no_such_room" : status >= 500 ? "internal" : "bad_request";
|
|
132
|
+
return new MediaError(code, message ?? `${status}`, status >= 500);
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
export type BookingCallProviderProps = {
|
|
105
136
|
bookingId: string;
|
|
106
137
|
/** Rendered while the first connection is in flight. */
|
|
@@ -136,59 +167,16 @@ export function BookingCallProvider({
|
|
|
136
167
|
);
|
|
137
168
|
}
|
|
138
169
|
|
|
139
|
-
/** How early the room opens, and how long it stays open. Mirrors the server. */
|
|
140
|
-
export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
|
|
141
|
-
export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
|
|
142
|
-
|
|
143
|
-
const MINUTE = 60_000;
|
|
144
|
-
|
|
145
|
-
/** What a booking has to carry for the window to be computable. */
|
|
146
|
-
export type BookingCallSubject = {
|
|
147
|
-
status: string;
|
|
148
|
-
sessionStartTime: string;
|
|
149
|
-
sessionEndTime: string;
|
|
150
|
-
location?: { type: string } | null;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
export type BookingCallWindow = {
|
|
154
|
-
/** Does this session happen on a platform video call at all? */
|
|
155
|
-
isVideo: boolean;
|
|
156
|
-
/** May a ticket be minted right now? Draw the Join control from this. */
|
|
157
|
-
isOpen: boolean;
|
|
158
|
-
/** Before the window, so the answer is "come back at". */
|
|
159
|
-
opensAt: Date;
|
|
160
|
-
/** After it the server refuses new tickets. */
|
|
161
|
-
closesAt: Date;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
170
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* able to talk itself into a room, and a clock that is ten minutes slow must
|
|
170
|
-
* not hide a session that has already started. Show the server's refusal
|
|
171
|
-
* verbatim when a join fails rather than pre-judging it here.
|
|
172
|
-
*
|
|
173
|
-
* `now` is a parameter so a caller can drive it from a ticking value and get a
|
|
174
|
-
* re-render at the moment the window opens; a hook reading the clock itself
|
|
175
|
-
* would be stale until something else happened to re-render.
|
|
171
|
+
* The window logic lives in `bookingWindow.ts`, a module with NO media
|
|
172
|
+
* dependency, so `AccountDashboard` (in the `./ui` entry every site loads) can
|
|
173
|
+
* decide whether to DRAW a Join control without pulling the SDK into that
|
|
174
|
+
* bundle. Re-exported here so `@tribe-nest/forge/media` keeps its surface.
|
|
176
175
|
*/
|
|
177
|
-
export
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// withholds `location` for anything that is not confirmed - so this reads
|
|
185
|
-
// `isVideo: false` for one, rather than offering a button that 409s.
|
|
186
|
-
const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
isVideo,
|
|
190
|
-
isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
|
|
191
|
-
opensAt,
|
|
192
|
-
closesAt,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
176
|
+
export {
|
|
177
|
+
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
178
|
+
BOOKING_CALL_OPENS_MINUTES_BEFORE,
|
|
179
|
+
bookingCallWindow,
|
|
180
|
+
type BookingCallSubject,
|
|
181
|
+
type BookingCallWindow,
|
|
182
|
+
} from "./bookingWindow";
|
|
@@ -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
|
}
|
package/src/ui/media/index.ts
CHANGED
|
@@ -93,6 +93,15 @@ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protoco
|
|
|
93
93
|
* for both.
|
|
94
94
|
*/
|
|
95
95
|
export { CallStage, type CallStageProps } from "./CallStage";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a call page shows OUTSIDE the window, when there is nothing to connect
|
|
99
|
+
* to. Exported because all three surfaces (admin, the client portal and a
|
|
100
|
+
* creator site) reached the same dead end: they mounted the call regardless,
|
|
101
|
+
* failed to connect, and reported "not yet connected", which answers neither of
|
|
102
|
+
* the two questions somebody arriving from an email is asking.
|
|
103
|
+
*/
|
|
104
|
+
export { CallWindowNotice, type CallWindowNoticeProps } from "./CallWindowNotice";
|
|
96
105
|
export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
|
|
97
106
|
export {
|
|
98
107
|
callHeadCount,
|
|
@@ -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,97 @@ 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 buyer's way into a coaching video call, on a code website.
|
|
1087
|
+
*
|
|
1088
|
+
* The room, the hooks, the credential endpoint and `BookingCallProvider` all
|
|
1089
|
+
* shipped before this control existed, and none of it was reachable from the
|
|
1090
|
+
* one place a client looks: the row for their session printed "Video call on
|
|
1091
|
+
* this site" and offered nothing to press. Admin's own help text told coaches
|
|
1092
|
+
* the client joins "from their account on your site", so this is where it has
|
|
1093
|
+
* to be. It renders the SAME `<CallStage>` the coach sees in admin.
|
|
1094
|
+
*
|
|
1095
|
+
* ## Why this is a LINK and not the call
|
|
1096
|
+
*
|
|
1097
|
+
* It used to mount the room inline, between two rows of a list, at whatever
|
|
1098
|
+
* width the portal happened to be. A call has its own full-screen page now
|
|
1099
|
+
* (`/i/video-calls/:callId`) and it is the SAME address the coach opens from
|
|
1100
|
+
* the dashboard, so both sides of a session meet in the same place instead of
|
|
1101
|
+
* in two different-looking products. Linking also keeps the media SDK out of
|
|
1102
|
+
* this bundle without needing a lazy boundary here: the page that needs
|
|
1103
|
+
* mediasoup is the only thing that loads it.
|
|
1104
|
+
*
|
|
1105
|
+
* A plain anchor rather than a router `Link`, because this component ships to
|
|
1106
|
+
* creator sites and must not assume which router mounted it.
|
|
1107
|
+
*
|
|
1108
|
+
* ## Why the window ticks
|
|
1109
|
+
*
|
|
1110
|
+
* `bookingCallWindow` takes `now` as an argument rather than reading the clock,
|
|
1111
|
+
* so a portal left open across the opening minute has to be told. A minute is
|
|
1112
|
+
* fine: the server applies the same window and refuses a ticket outside it,
|
|
1113
|
+
* and its refusal is what `<CallStage>` shows rather than a pre-judgement here.
|
|
1114
|
+
*/
|
|
1115
|
+
function BookingCallEntry({ booking }: { booking: MyBooking }) {
|
|
1116
|
+
const { button } = useCardStyles();
|
|
1117
|
+
const tr = useForgeT();
|
|
1118
|
+
const [now, setNow] = useState(() => new Date());
|
|
1119
|
+
|
|
1120
|
+
useEffect(() => {
|
|
1121
|
+
const timer = setInterval(() => setNow(new Date()), 30_000);
|
|
1122
|
+
return () => clearInterval(timer);
|
|
1123
|
+
}, []);
|
|
1124
|
+
|
|
1125
|
+
const call = bookingCallWindow(booking, now);
|
|
1126
|
+
if (!call.isVideo) return null;
|
|
1127
|
+
|
|
1128
|
+
if (!call.isOpen) {
|
|
1129
|
+
// State the buyer has to act on, so it is content and stays visible: the
|
|
1130
|
+
// room is not open yet, and this is when to come back.
|
|
1131
|
+
return (
|
|
1132
|
+
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
|
|
1133
|
+
{now < call.opensAt
|
|
1134
|
+
? tr("forge.account_dashboard.booking_video_opens_at", {
|
|
1135
|
+
datetime: call.opensAt.toLocaleString(undefined, {
|
|
1136
|
+
weekday: "short",
|
|
1137
|
+
day: "numeric",
|
|
1138
|
+
month: "short",
|
|
1139
|
+
hour: "numeric",
|
|
1140
|
+
minute: "2-digit",
|
|
1141
|
+
timeZone: booking.coachingProductTimezone ?? undefined,
|
|
1142
|
+
}),
|
|
1143
|
+
})
|
|
1144
|
+
: tr("forge.account_dashboard.booking_video_closed")}
|
|
1145
|
+
</div>
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Open, but no address: nothing to link to, so draw nothing.
|
|
1151
|
+
*
|
|
1152
|
+
* Deliberately AFTER the window branch, so a session that has not opened yet
|
|
1153
|
+
* still says when it does. That copy is about the SESSION and must not
|
|
1154
|
+
* depend on whether a room record exists.
|
|
1155
|
+
*
|
|
1156
|
+
* This is reachable only for a booking confirmed before calls became
|
|
1157
|
+
* addressable, since a confirmation now writes the record. The row above
|
|
1158
|
+
* still says the session is a video call, so the buyer is not left thinking
|
|
1159
|
+
* it is somewhere else; a dead button would be worse than none.
|
|
1160
|
+
*/
|
|
1161
|
+
if (!booking.videoCallId) return null;
|
|
1162
|
+
|
|
1163
|
+
return (
|
|
1164
|
+
<div style={{ marginTop: 12 }}>
|
|
1165
|
+
<a href={`/i/video-calls/${booking.videoCallId}`} style={{ ...button, display: "inline-block" }}>
|
|
1166
|
+
{tr("forge.account_dashboard.booking_join_video")}
|
|
1167
|
+
</a>
|
|
1076
1168
|
</div>
|
|
1077
1169
|
);
|
|
1078
1170
|
}
|