@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * An in-process media node, small enough to read in one sitting.
3
+ *
4
+ * The SDK has a fuller one (`media-client/src/core/_tests/fakeSignalServer.ts`,
5
+ * which validates every frame against the contract schema), and this is
6
+ * deliberately NOT that: it cannot be imported here. `@tribe-nest/media-client`
7
+ * publishes an exports map with four subpaths and none of them reaches into
8
+ * `src/core/_tests`, so an import of it would resolve inside this monorepo
9
+ * (npm workspaces symlink everything) and nowhere else. The same trap
10
+ * `_tests/publishedResolvability.spec.ts` exists for.
11
+ *
12
+ * What it has to do is exactly enough to get `<MediaRoomProvider>` to
13
+ * "connected" and then push frames at it: accept a join, answer a transport,
14
+ * answer a consume, and deliver whatever event a spec wants.
15
+ */
16
+
17
+ /**
18
+ * Restated rather than imported: `SocketEventMap` is internal to
19
+ * `media-client/src/core/socket.ts` and no published subpath reaches it. The
20
+ * shape has to match structurally or `webSocket={node.factory}` does not
21
+ * type-check, which is the point of writing it out.
22
+ */
23
+ type SocketEventMap = {
24
+ open: { type: "open" };
25
+ message: { type: "message"; data: unknown };
26
+ error: { type: "error"; error?: unknown };
27
+ close: { type: "close"; code?: number; reason?: string };
28
+ };
29
+
30
+ type SocketEvent = SocketEventMap[keyof SocketEventMap];
31
+ type Listener = (event: never) => void;
32
+
33
+ const OPEN = 1;
34
+ const CLOSED = 3;
35
+
36
+ export class FakeSocket {
37
+ readyState = 0;
38
+ private readonly listeners = new Map<string, Set<Listener>>();
39
+
40
+ constructor(private readonly node: FakeNode) {}
41
+
42
+ addEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void {
43
+ const set = this.listeners.get(type) ?? new Set<Listener>();
44
+ set.add(listener as Listener);
45
+ this.listeners.set(type, set);
46
+ }
47
+
48
+ removeEventListener<K extends keyof SocketEventMap>(type: K, listener: (event: SocketEventMap[K]) => void): void {
49
+ this.listeners.get(type)?.delete(listener as Listener);
50
+ }
51
+
52
+ send(data: string): void {
53
+ this.node.receive(this, JSON.parse(data) as Record<string, unknown>);
54
+ }
55
+
56
+ close(): void {
57
+ if (this.readyState === CLOSED) return;
58
+ this.readyState = CLOSED;
59
+ this.emit("close", { type: "close", code: 1000 });
60
+ }
61
+
62
+ open(): void {
63
+ this.readyState = OPEN;
64
+ this.emit("open", { type: "open" });
65
+ }
66
+
67
+ deliver(frame: unknown): void {
68
+ if (this.readyState !== OPEN) return;
69
+ this.emit("message", { type: "message", data: JSON.stringify(frame) });
70
+ }
71
+
72
+ /** The node going away underneath us, which is a reconnect and not a leave. */
73
+ drop(code = 1006): void {
74
+ if (this.readyState === CLOSED) return;
75
+ this.readyState = CLOSED;
76
+ this.emit("close", { type: "close", code, reason: "node closed" });
77
+ }
78
+
79
+ private emit(type: keyof SocketEventMap, event: SocketEvent): void {
80
+ for (const listener of [...(this.listeners.get(type) ?? [])]) {
81
+ queueMicrotask(() => (listener as (e: SocketEvent) => void)(event));
82
+ }
83
+ }
84
+ }
85
+
86
+ export type FakeProducer = { producerId: string; identity: string; kind: "audio" | "video" };
87
+
88
+ export type FakeNodeOptions = {
89
+ identity?: string;
90
+ peers?: { identity: string; name?: string; kind?: "human" | "agent" | "egress" | "sip" | "ingress" }[];
91
+ producers?: FakeProducer[];
92
+ recording?: boolean;
93
+ grants?: Record<string, unknown>;
94
+ /** Refuse the join with this code instead of accepting it. */
95
+ refuseWith?: string;
96
+ };
97
+
98
+ export class FakeNode {
99
+ readonly sockets: FakeSocket[] = [];
100
+ readonly received: Record<string, unknown>[] = [];
101
+
102
+ constructor(private readonly options: FakeNodeOptions = {}) {}
103
+
104
+ /** Pass as `webSocket` to `<MediaRoomProvider>`. */
105
+ readonly factory = (): FakeSocket => {
106
+ const socket = new FakeSocket(this);
107
+ this.sockets.push(socket);
108
+ queueMicrotask(() => socket.open());
109
+ return socket;
110
+ };
111
+
112
+ get socket(): FakeSocket {
113
+ const socket = this.sockets[this.sockets.length - 1];
114
+ if (!socket) throw new Error("no socket has been opened");
115
+ return socket;
116
+ }
117
+
118
+ receive(socket: FakeSocket, frame: Record<string, unknown>): void {
119
+ this.received.push(frame);
120
+ const id = frame.id as number;
121
+
122
+ if (frame.method === "join") {
123
+ if (this.options.refuseWith) {
124
+ socket.deliver({ id, ok: false, code: this.options.refuseWith });
125
+ return;
126
+ }
127
+ socket.deliver({ id, ok: true, data: { accepted: true } });
128
+ socket.deliver(this.joinedFrame());
129
+ return;
130
+ }
131
+
132
+ if (frame.method === "createTransport") {
133
+ socket.deliver({
134
+ id,
135
+ ok: true,
136
+ data: { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
137
+ });
138
+ return;
139
+ }
140
+
141
+ if (frame.method === "consume") {
142
+ const producerId = frame.producerId as string;
143
+ const known = (this.options.producers ?? []).find((p) => p.producerId === producerId);
144
+ socket.deliver({
145
+ id,
146
+ ok: true,
147
+ data: { consumerId: `c-${producerId}`, producerId, kind: known?.kind ?? "video", rtpParameters: {} },
148
+ });
149
+ return;
150
+ }
151
+
152
+ socket.deliver({ id, ok: true, data: {} });
153
+ }
154
+
155
+ joinedFrame(): Record<string, unknown> {
156
+ return {
157
+ event: "joined",
158
+ identity: this.options.identity ?? "me",
159
+ room: "booking-1",
160
+ routerRtpCapabilities: { codecs: [] },
161
+ peers: (this.options.peers ?? []).map((p) => ({ kind: "human", ...p })),
162
+ producers: this.options.producers ?? [],
163
+ iceServers: [],
164
+ recording: this.options.recording ?? false,
165
+ grants: this.options.grants ?? { canPublish: true, canSubscribe: true, canPublishData: false },
166
+ };
167
+ }
168
+
169
+ /** Push any event frame at the live socket. */
170
+ event(frame: Record<string, unknown>): void {
171
+ this.socket.deliver(frame);
172
+ }
173
+ }
174
+
175
+ /** Let the fake socket's queued microtasks land. */
176
+ export const settle = async (ms = 150): Promise<void> => {
177
+ await new Promise((resolve) => setTimeout(resolve, ms));
178
+ };
@@ -0,0 +1,194 @@
1
+ import { useCallback, type ReactNode } from "react";
2
+
3
+ import { MediaRoomProvider } from "@tribe-nest/media-client/react";
4
+ import type { MediaGrants } from "@tribe-nest/media-protocol";
5
+
6
+ import { useForge } from "../../provider/ForgeProvider";
7
+
8
+ /**
9
+ * Live video for a coaching session, on the platform's own media network.
10
+ *
11
+ * A coaching product whose location is `video` holds its sessions in a room
12
+ * this platform provisions, rather than on a link the coach pasted in. There is
13
+ * no URL to render and there deliberately never will be: entry is a signed
14
+ * ticket minted per attempt and valid for minutes, so anything durable enough
15
+ * to put in a page or an email would be a forwardable key to somebody else's
16
+ * session.
17
+ *
18
+ * ## What a site does
19
+ *
20
+ * ```tsx
21
+ * const call = bookingCallWindow(booking);
22
+ *
23
+ * return call.isOpen ? (
24
+ * <BookingCallProvider bookingId={booking.id} fallback={<p>Connecting…</p>}>
25
+ * <CallStage />
26
+ * </BookingCallProvider>
27
+ * ) : null;
28
+ * ```
29
+ *
30
+ * Inside the provider the whole surface is `useParticipants()`,
31
+ * `useRemoteTrack(producerId)`, `useLocalMedia()` and `useMediaRoom()` - the
32
+ * same hooks every other call surface uses, because there is one call UI.
33
+ *
34
+ * ## Why the credential is a CALLBACK
35
+ *
36
+ * `getCredentials` is called before EVERY connection attempt, not once. A join
37
+ * ticket expires in minutes and a session lasts an hour, so a token fetched
38
+ * once means the first reconnect after a lift or a wifi handover presents an
39
+ * expired one and the call does not come back. Passing the callback (rather
40
+ * than a token) is what makes reconnection work at all.
41
+ *
42
+ * ## What the server owns and this file does not mirror
43
+ *
44
+ * - **Who you are in the room.** Identity, name and grants are all derived
45
+ * server-side from the booking: the client who bought it, or the coach who
46
+ * holds `coaching.write` on the profile selling it. Nothing here can ask to
47
+ * be somebody else, and there is no field through which it could.
48
+ * - **Whether the room is open.** `bookingCallWindow` below is for DRAWING
49
+ * the button; the server refuses a ticket outside the window regardless, and
50
+ * its answer is the one that counts.
51
+ * - **Whether the session is a video session.** Read `booking.location.type`,
52
+ * already resolved server-side (this booking's override, else the product's
53
+ * default). Re-deriving it in the browser would give two answers to one
54
+ * question, and the wrong one sends a client to an empty room.
55
+ */
56
+
57
+ /** Which side of the call this viewer is on. Derived by the server, never asked for. */
58
+ export type BookingSessionRole = "host" | "guest";
59
+
60
+ /**
61
+ * One join ticket, plus the window it belongs to.
62
+ *
63
+ * The first six fields are the flat shape every credential endpoint on this
64
+ * platform returns and the media SDK consumes; `role` and the two window
65
+ * timestamps are extras the SDK ignores and a call UI usually wants.
66
+ */
67
+ export type BookingSessionCredentials = {
68
+ /** The signalling entrypoint. A load balancer, never a node this client picked. */
69
+ mediaUrl: string;
70
+ token: string;
71
+ /** ISO. When THIS ticket dies, not when the call does. */
72
+ expiresAt: string;
73
+ /** This viewer's identity in the room, as the other participants see it. */
74
+ identity: string;
75
+ roomId: string;
76
+ grants: MediaGrants;
77
+ role: BookingSessionRole;
78
+ /** ISO. The earliest moment the server will hand out a ticket for this session. */
79
+ roomOpensAt: string;
80
+ /** ISO. After this it refuses new tickets; anyone already connected stays. */
81
+ roomClosesAt: string;
82
+ };
83
+
84
+ /**
85
+ * Fetch a fresh join ticket for one booking. Hand this to
86
+ * `<MediaRoomProvider>`, or use `<BookingCallProvider>` which does it for you.
87
+ *
88
+ * The identity of the caller is the SESSION's - the endpoint takes the booking
89
+ * id and nothing else, and answers 404 for a booking that is not yours rather
90
+ * than 403, which would confirm the id exists.
91
+ */
92
+ export function useBookingSessionCredentials(bookingId: string): () => Promise<BookingSessionCredentials> {
93
+ const { client } = useForge();
94
+
95
+ // Stable across renders for a given booking: an inline arrow would be a new
96
+ // function on every parent render, and `<MediaRoomProvider>` holds its
97
+ // options in a ref precisely because that is the default mistake.
98
+ return useCallback(async () => {
99
+ const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
100
+ return res.data as BookingSessionCredentials;
101
+ }, [client, bookingId]);
102
+ }
103
+
104
+ export type BookingCallProviderProps = {
105
+ bookingId: string;
106
+ /** Rendered while the first connection is in flight. */
107
+ fallback?: ReactNode;
108
+ /**
109
+ * Follow the node's active speaker set automatically. On by default, and
110
+ * leaving it on is almost always right: a client that does not follow it
111
+ * shows black tiles in any room over the threshold.
112
+ */
113
+ autoSubscribe?: boolean;
114
+ children: ReactNode;
115
+ };
116
+
117
+ /**
118
+ * The room for one booking, wired to the credential endpoint.
119
+ *
120
+ * Mount it only when the call should be live. The provider connects on mount
121
+ * and closes on unmount, so rendering it behind a "Join" control is what stops
122
+ * a portal page from opening a media connection for every session in the list.
123
+ */
124
+ export function BookingCallProvider({
125
+ bookingId,
126
+ fallback,
127
+ autoSubscribe = true,
128
+ children,
129
+ }: BookingCallProviderProps): ReactNode {
130
+ const getCredentials = useBookingSessionCredentials(bookingId);
131
+
132
+ return (
133
+ <MediaRoomProvider getCredentials={getCredentials} autoSubscribe={autoSubscribe} fallback={fallback}>
134
+ {children}
135
+ </MediaRoomProvider>
136
+ );
137
+ }
138
+
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
+ /**
165
+ * Is this session's room open, and when does it open?
166
+ *
167
+ * For DRAWING the control and nothing else. The server applies the same window
168
+ * and its refusal is the decision: a clock that is ten minutes fast must not be
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.
176
+ */
177
+ export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
178
+ const startsAt = new Date(booking.sessionStartTime);
179
+ const endsAt = new Date(booking.sessionEndTime);
180
+ const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
181
+ const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
182
+
183
+ // A cancelled session has no room to enter, which is also why the server
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
+ }