@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.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +212 -0
  5. package/src/i18n/de.json +74 -1
  6. package/src/i18n/en.json +74 -1
  7. package/src/index.ts +7 -0
  8. package/src/types/models.ts +58 -5
  9. package/src/ui/headless/memberHome/index.ts +4 -0
  10. package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
  11. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  12. package/src/ui/index.ts +3 -0
  13. package/src/ui/media/CallStage.tsx +691 -553
  14. package/src/ui/media/PreJoin.tsx +269 -0
  15. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  16. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  17. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  18. package/src/ui/media/_tests/callState.spec.ts +93 -1
  19. package/src/ui/media/_tests/fakeNode.ts +4 -1
  20. package/src/ui/media/callChat.ts +33 -0
  21. package/src/ui/media/callState.ts +125 -9
  22. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  23. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  24. package/src/ui/media/index.ts +41 -1
  25. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  26. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  27. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  28. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  29. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  30. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  31. package/src/ui/media/stage/CallToasts.tsx +49 -0
  32. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  33. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  34. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  35. package/src/ui/media/stage/SelfView.tsx +146 -0
  36. package/src/ui/media/stage/VideoTile.tsx +232 -0
  37. package/src/ui/media/stage/callLayout.ts +253 -0
  38. package/src/ui/media/stage/callSignals.ts +91 -0
  39. package/src/ui/media/stage/callSounds.ts +78 -0
  40. package/src/ui/media/stage/stageTheme.ts +50 -0
  41. package/src/ui/styled/CohortPage.tsx +15 -1
  42. package/src/ui/styled/CourseAccess.tsx +19 -6
  43. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  44. package/src/ui/styled/FormRenderer.tsx +14 -4
  45. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  46. package/src/ui/styled/forge-utilities.css +13 -0
  47. package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
  48. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  49. 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 for this session is closed. If you think that is wrong, contact whoever you booked with.",
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";
@@ -1385,8 +1385,21 @@ export type CohortPageData = {
1385
1385
  title: string;
1386
1386
  description: string | null;
1387
1387
  order: number;
1388
- releaseOffsetDays: number | null;
1389
- releaseAt: string | null;
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
- sessions: Array<{
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
- scheduledAt: string;
1410
- durationMinutes: number;
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 the three visibility queries have settled (avoids tab flash). */
80
- isReady: !collectionsQ.isLoading && !spacesQ.isLoading && !channelsQ.isLoading,
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