@tribe-nest/forge 3.42.1 → 3.50.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 +2 -2
- package/src/data/queries/useCallChat.ts +162 -0
- package/src/data/queries/useCallRecording.ts +80 -0
- package/src/data/queries/useMembershipCalls.ts +212 -0
- package/src/i18n/de.json +74 -1
- package/src/i18n/en.json +74 -1
- package/src/index.ts +7 -0
- package/src/types/models.ts +58 -5
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/index.ts +3 -0
- package/src/ui/media/CallStage.tsx +691 -553
- package/src/ui/media/PreJoin.tsx +269 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
- package/src/ui/media/_tests/callLayout.spec.ts +182 -0
- package/src/ui/media/_tests/callSignals.spec.ts +87 -0
- package/src/ui/media/_tests/callState.spec.ts +93 -1
- package/src/ui/media/_tests/fakeNode.ts +4 -1
- package/src/ui/media/callChat.ts +33 -0
- package/src/ui/media/callState.ts +125 -9
- package/src/ui/media/hooks/useCallSignals.ts +135 -0
- package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
- package/src/ui/media/index.ts +41 -1
- package/src/ui/media/panels/CallChatPanel.tsx +200 -0
- package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
- package/src/ui/media/panels/CallSidePanel.tsx +152 -0
- package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
- package/src/ui/media/stage/CallControlsBar.tsx +547 -0
- package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
- package/src/ui/media/stage/CallToasts.tsx +49 -0
- package/src/ui/media/stage/CallTopBar.tsx +257 -0
- package/src/ui/media/stage/DeviceMenu.tsx +142 -0
- package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
- package/src/ui/media/stage/SelfView.tsx +146 -0
- package/src/ui/media/stage/VideoTile.tsx +232 -0
- package/src/ui/media/stage/callLayout.ts +253 -0
- package/src/ui/media/stage/callSignals.ts +91 -0
- package/src/ui/media/stage/callSounds.ts +78 -0
- package/src/ui/media/stage/stageTheme.ts +50 -0
- package/src/ui/styled/CohortPage.tsx +15 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
package/src/i18n/en.json
CHANGED
|
@@ -201,23 +201,83 @@
|
|
|
201
201
|
"forge.bundle_confirmation.view_orders": "View your orders",
|
|
202
202
|
"forge.call_stage.camera_start": "Start camera",
|
|
203
203
|
"forge.call_stage.camera_stop": "Stop camera",
|
|
204
|
+
"forge.call_stage.chat": "Chat",
|
|
205
|
+
"forge.call_stage.chat_empty": "No messages yet. Say hello.",
|
|
206
|
+
"forge.call_stage.chat_loading": "Opening the chat…",
|
|
207
|
+
"forge.call_stage.chat_placeholder": "Message everyone…",
|
|
208
|
+
"forge.call_stage.chat_send": "Send",
|
|
209
|
+
"forge.call_stage.chat_typing": "{name} is typing…",
|
|
210
|
+
"forge.call_stage.close_panel": "Close panel",
|
|
211
|
+
"forge.call_stage.dismiss": "Dismiss",
|
|
212
|
+
"forge.call_stage.everyone_left": "Everyone else has left the call",
|
|
213
|
+
"forge.call_stage.hand_raised_by": "{name} raised their hand",
|
|
214
|
+
"forge.call_stage.hands_raised_named": "{name} +{count}",
|
|
215
|
+
"forge.call_stage.hands_raised_open": "Raised hands: {count}. Open the people panel.",
|
|
204
216
|
"forge.call_stage.head_count": "In this call: {count}",
|
|
217
|
+
"forge.call_stage.health_close": "Close call health",
|
|
218
|
+
"forge.call_stage.health_estimate": "Bandwidth estimate",
|
|
219
|
+
"forge.call_stage.health_measuring": "Measuring...",
|
|
220
|
+
"forge.call_stage.health_no_video": "No video tiles to measure.",
|
|
221
|
+
"forge.call_stage.health_sending": "Sending",
|
|
222
|
+
"forge.call_stage.health_title": "Call health",
|
|
223
|
+
"forge.call_stage.health_toggle": "Call health",
|
|
205
224
|
"forge.call_stage.help_camera_stop": "It stops sending your picture: the others see your name instead. The browser keeps the camera open so that starting it again is instant and does not ask for permission a second time, which means its indicator light can stay on until you leave the call.",
|
|
206
225
|
"forge.call_stage.help_camera_stop_label": "What does Stop camera do?",
|
|
226
|
+
"forge.call_stage.help_health": "Each line shows a tile: the quality asked for and the quality being received (q quarter, h half, f full), then the decoded picture. A RED line is receiving less than it asked for. The bandwidth estimate is what the server believes this connection can carry; when it sits low, every tile is kept small. Under Sending, each of your own outgoing quality layers has a score; 0 means that layer is not reaching the server at all.",
|
|
227
|
+
"forge.call_stage.help_health_label": "How do I read this?",
|
|
207
228
|
"forge.call_stage.help_hear_not_see": "A tile with a name and no picture means that person has their camera off, or the call is busy enough that only the people currently speaking are sent in full. Both are normal, and their audio is unaffected.",
|
|
208
229
|
"forge.call_stage.help_hear_not_see_label": "Why can I hear someone but not see them?",
|
|
230
|
+
"forge.call_stage.help_record": "Recording starts now and captures everyone in the call. It is assembled into one video after the call ends, which takes a few minutes, and then appears for the people who could join this call.",
|
|
231
|
+
"forge.call_stage.help_record_label": "Who can see the recording?",
|
|
209
232
|
"forge.call_stage.help_screen": "Whatever you pick in the browser prompt, live, to everyone in the call. Your camera keeps running alongside it rather than being replaced, so people can still see you while you present.",
|
|
210
233
|
"forge.call_stage.help_screen_label": "What does sharing my screen send?",
|
|
234
|
+
"forge.call_stage.hide_self_view": "Hide self view",
|
|
211
235
|
"forge.call_stage.leave": "Leave",
|
|
236
|
+
"forge.call_stage.leave_recording_body": "This call is being recorded. If you leave without stopping, the recording keeps running until everybody has left.",
|
|
237
|
+
"forge.call_stage.leave_recording_cancel": "Stay on the call",
|
|
238
|
+
"forge.call_stage.leave_recording_keep": "Leave, keep recording",
|
|
239
|
+
"forge.call_stage.leave_recording_stop": "Stop recording and leave",
|
|
240
|
+
"forge.call_stage.leave_recording_title": "You are still recording",
|
|
212
241
|
"forge.call_stage.listening": "(listening)",
|
|
213
242
|
"forge.call_stage.lost_camera": "Your camera was turned off when the connection dropped. Start it again to be seen.",
|
|
214
243
|
"forge.call_stage.lost_microphone": "Your microphone was turned off when the connection dropped. Unmute to be heard again.",
|
|
215
244
|
"forge.call_stage.lost_screen": "Your screen share ended when the connection dropped. Share it again to continue.",
|
|
245
|
+
"forge.call_stage.lower_all_hands": "Lower all hands",
|
|
246
|
+
"forge.call_stage.lower_hand": "Lower hand",
|
|
247
|
+
"forge.call_stage.lower_hand_of": "Lower",
|
|
248
|
+
"forge.call_stage.many_joined": "{count} people joined or left",
|
|
216
249
|
"forge.call_stage.mute": "Mute",
|
|
250
|
+
"forge.call_stage.muted_talking": "You appear to be talking while muted.",
|
|
251
|
+
"forge.call_stage.no_devices": "No devices found",
|
|
252
|
+
"forge.call_stage.page_next": "Next page",
|
|
253
|
+
"forge.call_stage.page_of": "{page} / {pages}",
|
|
254
|
+
"forge.call_stage.page_prev": "Previous page",
|
|
255
|
+
"forge.call_stage.peer_joined": "{name} joined",
|
|
256
|
+
"forge.call_stage.peer_left": "{name} left",
|
|
257
|
+
"forge.call_stage.people": "People",
|
|
258
|
+
"forge.call_stage.pick_camera": "Choose a camera",
|
|
259
|
+
"forge.call_stage.pick_microphone": "Choose a microphone",
|
|
260
|
+
"forge.call_stage.pin": "Pin this tile",
|
|
261
|
+
"forge.call_stage.prejoin_camera_off": "Your camera is off",
|
|
262
|
+
"forge.call_stage.prejoin_camera_off_toggle": "Camera off",
|
|
263
|
+
"forge.call_stage.prejoin_camera_on": "Camera on",
|
|
264
|
+
"forge.call_stage.prejoin_join": "Join the call",
|
|
265
|
+
"forge.call_stage.prejoin_mic_off": "Mic off",
|
|
266
|
+
"forge.call_stage.prejoin_mic_on": "Mic on",
|
|
267
|
+
"forge.call_stage.prejoin_permission": "The browser did not grant access to your devices:",
|
|
268
|
+
"forge.call_stage.presenting_name": "{name} is presenting",
|
|
269
|
+
"forge.call_stage.raise_hand": "Raise hand",
|
|
270
|
+
"forge.call_stage.react_to_message": "React to this message",
|
|
271
|
+
"forge.call_stage.reactions": "Send a reaction",
|
|
272
|
+
"forge.call_stage.record_start": "Record",
|
|
273
|
+
"forge.call_stage.record_stop": "Stop recording",
|
|
217
274
|
"forge.call_stage.recording": "This call is being recorded",
|
|
275
|
+
"forge.call_stage.recording_live": "Recording",
|
|
218
276
|
"forge.call_stage.retry": "Try again",
|
|
277
|
+
"forge.call_stage.screen_of": "{name}'s screen",
|
|
219
278
|
"forge.call_stage.screen_start": "Share screen",
|
|
220
279
|
"forge.call_stage.screen_stop": "Stop sharing",
|
|
280
|
+
"forge.call_stage.speaking_off_page": "Speaking: {names}",
|
|
221
281
|
"forge.call_stage.status_connected": "Connected",
|
|
222
282
|
"forge.call_stage.status_connecting": "Connecting to the call",
|
|
223
283
|
"forge.call_stage.status_ended": "The call has ended",
|
|
@@ -231,11 +291,23 @@
|
|
|
231
291
|
"forge.call_stage.status_reconnecting": "Connection lost, reconnecting",
|
|
232
292
|
"forge.call_stage.status_reconnecting_detail": "Everyone else is still in the call.",
|
|
233
293
|
"forge.call_stage.status_refused": "Could not join this call",
|
|
294
|
+
"forge.call_stage.stop_presenting": "Stop presenting",
|
|
295
|
+
"forge.call_stage.tab_chat": "Chat",
|
|
296
|
+
"forge.call_stage.tab_people": "People",
|
|
234
297
|
"forge.call_stage.unmute": "Unmute",
|
|
298
|
+
"forge.call_stage.unnamed_device": "Device {number}",
|
|
299
|
+
"forge.call_stage.unpin": "Unpin this tile",
|
|
300
|
+
"forge.call_stage.view_gallery": "Gallery view",
|
|
301
|
+
"forge.call_stage.view_speaker": "Speaker view",
|
|
302
|
+
"forge.call_stage.waiting_for_others": "Waiting for others to join",
|
|
303
|
+
"forge.call_stage.waiting_for_others_detail": "You are the first one here. The call starts when somebody else arrives.",
|
|
235
304
|
"forge.call_stage.you": "You",
|
|
305
|
+
"forge.call_stage.you_are_presenting": "You are presenting your screen",
|
|
306
|
+
"forge.call_stage.you_joining": "You (joining…)",
|
|
236
307
|
"forge.call_stage.you_not_connected": "You (not connected yet)",
|
|
308
|
+
"forge.call_stage.you_suffix": "(you)",
|
|
237
309
|
"forge.call_window.ended": "This call has ended",
|
|
238
|
-
"forge.call_window.ended_detail": "The room
|
|
310
|
+
"forge.call_window.ended_detail": "The room is closed, so nobody can join it now. If you think that is wrong, ask the host.",
|
|
239
311
|
"forge.call_window.not_started": "This call has not started yet",
|
|
240
312
|
"forge.call_window.opens_at": "You can join from {datetime}. Come back then and this page will let you in.",
|
|
241
313
|
"forge.cancellation_terms.title": "Cancellation policy",
|
|
@@ -500,6 +572,7 @@
|
|
|
500
572
|
"forge.course_access.secure_prompt_lead": "Anyone with this link can open your course. Create an account with",
|
|
501
573
|
"forge.course_access.secure_prompt_tail": "to keep it to yourself.",
|
|
502
574
|
"forge.course_access.signed_in_as": "Signed in as",
|
|
575
|
+
"forge.course_calls.title": "Live calls",
|
|
503
576
|
"forge.course_checkout.aria_close": "Close",
|
|
504
577
|
"forge.course_checkout.back": "Back",
|
|
505
578
|
"forge.course_checkout.confirm_email_placeholder": "Confirm email",
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,12 @@ export * from "./data/queries/useAccount";
|
|
|
120
120
|
export * from "./data/queries/useMyTickets";
|
|
121
121
|
export * from "./data/queries/usePassTransfers";
|
|
122
122
|
export * from "./data/queries/useMyBookings";
|
|
123
|
+
// The member's own call calendar. Beside `useMyBookings` and `useMyTickets`
|
|
124
|
+
// because it answers the same shape of question: what have I got coming up
|
|
125
|
+
// with this artist, and where do I go for it.
|
|
126
|
+
export * from "./data/queries/useMembershipCalls";
|
|
127
|
+
// The host's recording control, for ANY call: it addresses the call, not a domain.
|
|
128
|
+
export * from "./data/queries/useCallRecording";
|
|
123
129
|
export * from "./data/queries/useEventWaitlist";
|
|
124
130
|
export * from "./data/queries/useWalletPass";
|
|
125
131
|
export * from "./data/queries/useFinalize";
|
|
@@ -137,6 +143,7 @@ export * from "./data/queries/useWebsite";
|
|
|
137
143
|
export * from "./data/queries/useWebsiteAgent";
|
|
138
144
|
export * from "./data/queries/useCurrencies";
|
|
139
145
|
export * from "./data/queries/useChat";
|
|
146
|
+
export * from "./data/queries/useCallChat";
|
|
140
147
|
export * from "./data/queries/useAnalytics";
|
|
141
148
|
export * from "./data/queries/useCommunity";
|
|
142
149
|
export * from "./data/queries/useReviews";
|
package/src/types/models.ts
CHANGED
|
@@ -1385,8 +1385,21 @@ export type CohortPageData = {
|
|
|
1385
1385
|
title: string;
|
|
1386
1386
|
description: string | null;
|
|
1387
1387
|
order: number;
|
|
1388
|
-
|
|
1389
|
-
|
|
1388
|
+
/**
|
|
1389
|
+
* The drip schedule and the answer it produces. Present only on the
|
|
1390
|
+
* ENROLLED read: the marketing payload gives a browser the shape of the
|
|
1391
|
+
* course, and a student its calendar.
|
|
1392
|
+
*
|
|
1393
|
+
* Render `locked` / `unlocksAt` / `lockReason`, not the raw offset. The
|
|
1394
|
+
* server computes them with the same expression the classroom uses, so a
|
|
1395
|
+
* renderer that re-derives the answer from `releaseOffsetDays` will
|
|
1396
|
+
* eventually disagree with the screen the student opens next.
|
|
1397
|
+
*/
|
|
1398
|
+
releaseOffsetDays?: number | null;
|
|
1399
|
+
releaseAt?: string | null;
|
|
1400
|
+
locked?: boolean;
|
|
1401
|
+
unlocksAt?: string | null;
|
|
1402
|
+
lockReason?: "scheduled" | "cohort_not_started" | null;
|
|
1390
1403
|
lessons?: Array<{
|
|
1391
1404
|
id: string;
|
|
1392
1405
|
title: string;
|
|
@@ -1402,12 +1415,21 @@ export type CohortPageData = {
|
|
|
1402
1415
|
publishedAt: string | null;
|
|
1403
1416
|
createdAt: string;
|
|
1404
1417
|
}>;
|
|
1405
|
-
|
|
1418
|
+
/**
|
|
1419
|
+
* The cohort's scheduled calls. Renamed from `sessions` when
|
|
1420
|
+
* `cohort_sessions` was dropped for the unified `scheduled_calls` table.
|
|
1421
|
+
*
|
|
1422
|
+
* Carries NO join address on purpose. The endpoint behind this payload is
|
|
1423
|
+
* anonymous, so a `callPublicId` here would be a door into a live paid room.
|
|
1424
|
+
* A student joins from the classroom, where they are authenticated.
|
|
1425
|
+
*/
|
|
1426
|
+
calls: Array<{
|
|
1406
1427
|
id: string;
|
|
1407
1428
|
title: string;
|
|
1408
1429
|
description: string | null;
|
|
1409
|
-
|
|
1410
|
-
|
|
1430
|
+
startsAt: string;
|
|
1431
|
+
endsAt: string;
|
|
1432
|
+
timezone: string | null;
|
|
1411
1433
|
status: string;
|
|
1412
1434
|
}>;
|
|
1413
1435
|
pricingPlans: Array<{
|
|
@@ -1653,6 +1675,18 @@ export type CourseAccessRecord = {
|
|
|
1653
1675
|
* value as legacy (see `isLegacyCourseAccess`).
|
|
1654
1676
|
*/
|
|
1655
1677
|
accountId?: string | null;
|
|
1678
|
+
/**
|
|
1679
|
+
* The cohort this enrolment is for, or `null` for a self-paced buy.
|
|
1680
|
+
*
|
|
1681
|
+
* Decides which MODULES the payload carries (a cohort holds its own cloned
|
|
1682
|
+
* copy) and, on the client, which live calls belong on this classroom page: a
|
|
1683
|
+
* student enrolled in two cohorts of one course has two grants and two
|
|
1684
|
+
* classrooms, and each must show only its own sessions.
|
|
1685
|
+
*
|
|
1686
|
+
* Optional on the type because sites built against an older API still get
|
|
1687
|
+
* payloads without the field.
|
|
1688
|
+
*/
|
|
1689
|
+
cohortId?: string | null;
|
|
1656
1690
|
};
|
|
1657
1691
|
|
|
1658
1692
|
export type CourseLessonProgress = {
|
|
@@ -1683,6 +1717,25 @@ export type CourseModule = {
|
|
|
1683
1717
|
description: string | null;
|
|
1684
1718
|
order: number;
|
|
1685
1719
|
lessons: CourseLesson[];
|
|
1720
|
+
/**
|
|
1721
|
+
* Drip state, on a cohort course. Optional because a self-paced course never
|
|
1722
|
+
* carries it and every site built before drip existed predates these fields.
|
|
1723
|
+
*
|
|
1724
|
+
* `locked` means the module has not been released to this student yet. The
|
|
1725
|
+
* server already strips `content` and `media` from a locked module's lessons,
|
|
1726
|
+
* so a renderer that ignores these fields is not a content leak. It is a
|
|
1727
|
+
* BROKEN SCREEN: the lessons render as normal, clickable, and open to
|
|
1728
|
+
* nothing. Render the lock.
|
|
1729
|
+
*
|
|
1730
|
+
* `unlocksAt` is the EFFECTIVE moment, after the cohort's start date and the
|
|
1731
|
+
* module's offset have been applied. It is null when the answer is not known
|
|
1732
|
+
* yet, which is what `lockReason: "cohort_not_started"` means: the creator
|
|
1733
|
+
* laid out the weeks before picking a start date. Say "opens when the cohort
|
|
1734
|
+
* starts" there rather than showing an empty date.
|
|
1735
|
+
*/
|
|
1736
|
+
locked?: boolean;
|
|
1737
|
+
unlocksAt?: string | null;
|
|
1738
|
+
lockReason?: "scheduled" | "cohort_not_started" | null;
|
|
1686
1739
|
};
|
|
1687
1740
|
|
|
1688
1741
|
export type CourseAccessCourse = {
|
|
@@ -5,3 +5,7 @@ export {
|
|
|
5
5
|
type MemberHomeNavKey,
|
|
6
6
|
type UseMemberHomeNavOptions,
|
|
7
7
|
} from "./useMemberHomeNav";
|
|
8
|
+
|
|
9
|
+
// The member call calendar's model. Grouping lives here so the two rendering
|
|
10
|
+
// surfaces cannot disagree about which calls are "today".
|
|
11
|
+
export { useMemberCalls, type MemberCalendarDay, type UseMemberCallsOptions } from "./useMemberCalls";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useMyMembershipCalls, type MyMembershipCall } from "../../../data/queries/useMembershipCalls";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Headless model for the member CALENDAR: a month you can page through, with
|
|
6
|
+
* upcoming calls and past ones side by side.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a month and not a list
|
|
9
|
+
*
|
|
10
|
+
* A list can only ever run forwards. A member who missed Tuesday has nowhere to
|
|
11
|
+
* go looking for it, and a member wondering what is on next month cannot ask.
|
|
12
|
+
* The month is the unit people already think in for "when is that call", and
|
|
13
|
+
* paging it is what makes both directions reachable.
|
|
14
|
+
*
|
|
15
|
+
* ## The zone
|
|
16
|
+
*
|
|
17
|
+
* A call carries the creator's `timezone` when they set one. When they did not,
|
|
18
|
+
* the fallback is the VIEWER's zone rather than UTC - the instant is absolute
|
|
19
|
+
* either way, and UTC is a time nobody is in. This deliberately differs from
|
|
20
|
+
* `EventDetail`, whose UTC fallback is about a published page that must read the
|
|
21
|
+
* same to everybody who links to it; a personal calendar has every reason to be
|
|
22
|
+
* local.
|
|
23
|
+
*
|
|
24
|
+
* The GRID, though, is laid out in one zone - the viewer's - because a month has
|
|
25
|
+
* to have one shape. A call at 1am Tokyo on the 1st is on the 1st in the grid if
|
|
26
|
+
* that is where the viewer's clock puts it, and its own row still prints the
|
|
27
|
+
* creator's time.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface MemberCalendarDay {
|
|
31
|
+
/** `YYYY-MM-DD` in the viewer's zone. Stable key for a cell. */
|
|
32
|
+
key: string;
|
|
33
|
+
date: Date;
|
|
34
|
+
/** False for the leading and trailing days that pad the grid to whole weeks. */
|
|
35
|
+
inMonth: boolean;
|
|
36
|
+
isToday: boolean;
|
|
37
|
+
isPast: boolean;
|
|
38
|
+
calls: MyMembershipCall[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseMemberCallsOptions {
|
|
42
|
+
limit?: number;
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Injected so a test can pin it. Not a prop anybody passes in production -
|
|
46
|
+
* "today" is genuinely now, and a stale value would mark the wrong cell.
|
|
47
|
+
*/
|
|
48
|
+
now?: Date;
|
|
49
|
+
/** Monday-first. The repo's `utils/recurrence` fixes the same convention. */
|
|
50
|
+
weekStartsOn?: 0 | 1;
|
|
51
|
+
/**
|
|
52
|
+
* Narrow the month to ONE course, for a calendar rendered inside a classroom
|
|
53
|
+
* rather than in the members area.
|
|
54
|
+
*
|
|
55
|
+
* Absent on the members calendar, which is the viewer's whole schedule and
|
|
56
|
+
* must stay so. Present, the server intersects it with the audience keys it
|
|
57
|
+
* already resolved for this caller, so it can only ever remove rows.
|
|
58
|
+
*/
|
|
59
|
+
courseId?: string | null;
|
|
60
|
+
/** The same, for one cohort. A student in two cohorts of one course sees only this one's sessions. */
|
|
61
|
+
cohortId?: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const dayKey = (date: Date): string => date.toLocaleDateString("en-CA");
|
|
65
|
+
|
|
66
|
+
/** Midnight local on the first of the month `offset` months from `from`. */
|
|
67
|
+
const monthStart = (from: Date, offset = 0): Date =>
|
|
68
|
+
new Date(from.getFullYear(), from.getMonth() + offset, 1);
|
|
69
|
+
|
|
70
|
+
export function useMemberCalls(options: UseMemberCallsOptions = {}) {
|
|
71
|
+
const now = options.now ?? new Date();
|
|
72
|
+
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
73
|
+
|
|
74
|
+
/** Which month is on screen, as an offset from the one containing `now`. */
|
|
75
|
+
const [monthOffset, setMonthOffset] = useState(0);
|
|
76
|
+
const visibleMonth = monthStart(now, monthOffset);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The window fetched, which is the whole GRID rather than the month.
|
|
80
|
+
*
|
|
81
|
+
* A month view shows the tail of the previous month and the head of the next,
|
|
82
|
+
* and a call in one of those cells is a real call the member can act on.
|
|
83
|
+
* Fetching only the month would render those days permanently empty, which
|
|
84
|
+
* reads as "nothing on" rather than "not asked about".
|
|
85
|
+
*/
|
|
86
|
+
const gridStart = useMemo(() => {
|
|
87
|
+
const first = new Date(visibleMonth);
|
|
88
|
+
const shift = (first.getDay() - weekStartsOn + 7) % 7;
|
|
89
|
+
return new Date(first.getFullYear(), first.getMonth(), first.getDate() - shift);
|
|
90
|
+
}, [visibleMonth.getTime(), weekStartsOn]);
|
|
91
|
+
|
|
92
|
+
// Six weeks always, so the grid does not change height as you page - a layout
|
|
93
|
+
// that jumps between months is one the eye has to re-find every click.
|
|
94
|
+
const gridEnd = useMemo(
|
|
95
|
+
() => new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + 42),
|
|
96
|
+
[gridStart.getTime()],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const query = useMyMembershipCalls({
|
|
100
|
+
from: gridStart,
|
|
101
|
+
to: gridEnd,
|
|
102
|
+
limit: options.limit,
|
|
103
|
+
enabled: options.enabled,
|
|
104
|
+
courseId: options.courseId,
|
|
105
|
+
cohortId: options.cohortId,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const days: MemberCalendarDay[] = useMemo(() => {
|
|
109
|
+
const byDay = new Map<string, MyMembershipCall[]>();
|
|
110
|
+
for (const call of query.data ?? []) {
|
|
111
|
+
// Bucketed by the VIEWER's day, which is the grid's zone. See the header.
|
|
112
|
+
const key = dayKey(new Date(call.startsAt));
|
|
113
|
+
const existing = byDay.get(key);
|
|
114
|
+
if (existing) existing.push(call);
|
|
115
|
+
else byDay.set(key, [call]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const todayKey = dayKey(now);
|
|
119
|
+
return Array.from({ length: 42 }, (_, index) => {
|
|
120
|
+
const date = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index);
|
|
121
|
+
const key = dayKey(date);
|
|
122
|
+
return {
|
|
123
|
+
key,
|
|
124
|
+
date,
|
|
125
|
+
inMonth: date.getMonth() === visibleMonth.getMonth() && date.getFullYear() === visibleMonth.getFullYear(),
|
|
126
|
+
isToday: key === todayKey,
|
|
127
|
+
isPast: key < todayKey,
|
|
128
|
+
calls: byDay.get(key) ?? [],
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
}, [query.data, gridStart.getTime(), visibleMonth.getTime(), now.getTime()]);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
/** 42 cells, six whole weeks. */
|
|
136
|
+
days,
|
|
137
|
+
visibleMonth,
|
|
138
|
+
monthLabel: visibleMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
|
139
|
+
/** True when the grid is showing the month `now` falls in. */
|
|
140
|
+
isCurrentMonth: monthOffset === 0,
|
|
141
|
+
goToPreviousMonth: () => setMonthOffset((offset) => offset - 1),
|
|
142
|
+
goToNextMonth: () => setMonthOffset((offset) => offset + 1),
|
|
143
|
+
goToToday: () => setMonthOffset(0),
|
|
144
|
+
/** Every call in the fetched window, chronological. Drives the list beneath the grid. */
|
|
145
|
+
calls: query.data ?? [],
|
|
146
|
+
isLoading: query.isLoading,
|
|
147
|
+
error: query.error as Error | null,
|
|
148
|
+
refetch: query.refetch,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -2,15 +2,16 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { useGetPostCollections } from "../../../data/queries/usePostCollections";
|
|
3
3
|
import { useCommunitySpaces } from "../../../data/queries/useCommunity";
|
|
4
4
|
import { useChatChannels } from "../../../data/queries/useChat";
|
|
5
|
+
import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
|
|
5
6
|
|
|
6
|
-
export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "chat" | "membership";
|
|
7
|
+
export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "calendar" | "chat" | "membership";
|
|
7
8
|
|
|
8
9
|
export interface MemberHomeNavItem {
|
|
9
10
|
key: MemberHomeNavKey;
|
|
10
11
|
label: string;
|
|
11
12
|
/** Absolute in-site href (already run through `buildHref` when supplied). */
|
|
12
13
|
href: string;
|
|
13
|
-
/** Conditional tabs (Collections/Spaces/Chat) are hidden until the profile has content for them. */
|
|
14
|
+
/** Conditional tabs (Collections/Spaces/Calendar/Chat) are hidden until the profile has content for them. */
|
|
14
15
|
visible: boolean;
|
|
15
16
|
/** Whether `currentPath` falls inside this tab. */
|
|
16
17
|
active: boolean;
|
|
@@ -52,10 +53,29 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
|
52
53
|
const collectionsQ = useGetPostCollections({ page: 1 });
|
|
53
54
|
const spacesQ = useCommunitySpaces();
|
|
54
55
|
const channelsQ = useChatChannels();
|
|
56
|
+
/**
|
|
57
|
+
* Upcoming calls this member may join. Already filtered by the server against
|
|
58
|
+
* their membership, so a non-empty list IS the visibility rule - there is no
|
|
59
|
+
* second "can they get in" check to make here, and no way for the tab to
|
|
60
|
+
* appear over an empty page.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* ANY call this viewer may see, ever - not just an upcoming one.
|
|
64
|
+
*
|
|
65
|
+
* The Calendar is a month you can page backwards through, so it has something
|
|
66
|
+
* to show from the moment the creator has ever run a call. Keying the tab on
|
|
67
|
+
* upcoming calls alone hid the whole history the hour the last one ended,
|
|
68
|
+
* which is precisely when somebody goes looking for the recording.
|
|
69
|
+
*
|
|
70
|
+
* `limit: 1` because this is an existence check: the calendar fetches its own
|
|
71
|
+
* window, and the answer here is only "draw the tab or not".
|
|
72
|
+
*/
|
|
73
|
+
const callsQ = useMyMembershipCalls({ from: new Date(0), limit: 1 });
|
|
55
74
|
|
|
56
75
|
const hasCollections = (collectionsQ.data?.data?.length ?? 0) > 0;
|
|
57
76
|
const hasSpaces = (spacesQ.data?.length ?? 0) > 0;
|
|
58
77
|
const hasChannels = (channelsQ.data?.length ?? 0) > 0;
|
|
78
|
+
const hasCalls = (callsQ.data?.length ?? 0) > 0;
|
|
59
79
|
|
|
60
80
|
const items: MemberHomeNavItem[] = useMemo(() => {
|
|
61
81
|
const isUnder = (segment: string) =>
|
|
@@ -64,19 +84,23 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
|
64
84
|
{ key: "posts", label: "Posts", path: basePath, visible: true, external: false, active: currentPath === normalize(basePath) },
|
|
65
85
|
{ key: "collections", label: "Collections", path: `${basePath}/collections`, visible: hasCollections, external: false, active: isUnder("collections") },
|
|
66
86
|
{ key: "spaces", label: "Spaces", path: `${basePath}/spaces`, visible: hasSpaces, external: false, active: isUnder("spaces") },
|
|
87
|
+
// Scheduled calls. Sits above Chat because a call has a TIME on it: it is
|
|
88
|
+
// the one tab whose content stops being useful if it is found late.
|
|
89
|
+
{ key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: hasCalls, external: false, active: isUnder("calendar") },
|
|
67
90
|
{ key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: isUnder("chat") },
|
|
68
91
|
{ key: "membership", label: "My membership", path: membershipHref, visible: true, external: true, active: false },
|
|
69
92
|
];
|
|
70
93
|
return defs.map(({ path, ...d }) => ({ ...d, href: buildHref(path) }));
|
|
71
94
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
-
}, [basePath, membershipHref, currentPath, hasCollections, hasSpaces, hasChannels]);
|
|
95
|
+
}, [basePath, membershipHref, currentPath, hasCollections, hasSpaces, hasCalls, hasChannels]);
|
|
73
96
|
|
|
74
97
|
return {
|
|
75
98
|
items,
|
|
76
99
|
/** Visible items only — what a shell actually renders. */
|
|
77
100
|
visibleItems: items.filter((i) => i.visible),
|
|
78
101
|
activeKey: items.find((i) => i.active)?.key ?? null,
|
|
79
|
-
/** True once
|
|
80
|
-
isReady:
|
|
102
|
+
/** True once every visibility query has settled (avoids tab flash). */
|
|
103
|
+
isReady:
|
|
104
|
+
!collectionsQ.isLoading && !spacesQ.isLoading && !callsQ.isLoading && !channelsQ.isLoading,
|
|
81
105
|
};
|
|
82
106
|
}
|
package/src/ui/index.ts
CHANGED
|
@@ -198,6 +198,9 @@ export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/Con
|
|
|
198
198
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
199
199
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
200
200
|
export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
|
|
201
|
+
// A student's live calls, scoped to the course or cohort the page is standing
|
|
202
|
+
// on. A thin wrapper over `MemberCallsCalendar`, not a second calendar.
|
|
203
|
+
export { CourseCallsSection, type CourseCallsSectionProps } from "./styled/CourseCallsSection";
|
|
201
204
|
|
|
202
205
|
// Films and series (Tier 2). `FilmWatch` owns the entitlement gate, the viewer
|
|
203
206
|
// overlay, the episode switcher and the clock; it takes a `renderPlayer` prop
|