@tribe-nest/forge 3.48.0 → 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 +1 -1
- package/src/data/queries/useMembershipCalls.ts +58 -8
- package/src/i18n/de.json +1 -0
- package/src/i18n/en.json +1 -0
- package/src/types/models.ts +58 -5
- package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
- package/src/ui/index.ts +3 -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/members/MemberCallsCalendar.tsx +19 -1
package/package.json
CHANGED
|
@@ -2,12 +2,23 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
5
|
+
* The viewer's own call calendar (`GET /public/scheduled-calls/mine`).
|
|
6
6
|
*
|
|
7
7
|
* Lives in Forge rather than in either app because BOTH rendering surfaces need
|
|
8
8
|
* exactly this - the site-starter's `/i/members/calendar` and the client app's
|
|
9
|
-
* `/i/members/calendar
|
|
10
|
-
* `useMyTickets` and `useMyBookings`
|
|
9
|
+
* `/i/members/calendar`, and now the classroom and cohort pages on both - and a
|
|
10
|
+
* second copy is how the two drift, as `useMyTickets` and `useMyBookings`
|
|
11
|
+
* already document.
|
|
12
|
+
*
|
|
13
|
+
* ## It was never only memberships
|
|
14
|
+
*
|
|
15
|
+
* The server resolves the caller through every registered audience kind, so a
|
|
16
|
+
* course enrolment and a cohort seat come back alongside membership tiers. The
|
|
17
|
+
* endpoint answered on `/public/membership-calls/mine` for a year while doing
|
|
18
|
+
* exactly that; that path is kept as an alias for sites published against older
|
|
19
|
+
* Forge builds, and this file now calls the honest one. The names here keep the
|
|
20
|
+
* `MembershipCall` spelling because they are PUBLISHED exports that a code site
|
|
21
|
+
* may import; `useMyScheduledCalls` is the alias to write new code against.
|
|
11
22
|
*
|
|
12
23
|
* ## What the server owns and this file does not mirror
|
|
13
24
|
*
|
|
@@ -84,24 +95,49 @@ export const membershipCallKeys = {
|
|
|
84
95
|
* @param options.from Inclusive lower bound. Absent means "from now on", which is
|
|
85
96
|
* what a caller wanting only the next few calls sends.
|
|
86
97
|
* @param options.to Inclusive upper bound. Absent means "however far ahead".
|
|
98
|
+
* @param options.courseId Show only calls addressed to this course. See below.
|
|
99
|
+
* @param options.cohortId Show only calls addressed to this cohort. See below.
|
|
100
|
+
*
|
|
101
|
+
* ## `courseId` / `cohortId` narrow, they do not unlock
|
|
102
|
+
*
|
|
103
|
+
* Absent, this is the viewer's whole calendar and behaves as it always has.
|
|
104
|
+
* Present, the server INTERSECTS them with the audience keys it resolved for
|
|
105
|
+
* this caller, so they can only remove rows. Passing a course the viewer has no
|
|
106
|
+
* access to returns nothing, which is the same answer a stranger gets.
|
|
107
|
+
*
|
|
108
|
+
* The classroom and the cohort page pass them because a student enrolled in two
|
|
109
|
+
* cohorts opening one of them must not be shown the other's sessions.
|
|
87
110
|
*/
|
|
88
111
|
export function useMyMembershipCalls(options?: {
|
|
89
112
|
from?: Date;
|
|
90
113
|
to?: Date;
|
|
91
114
|
limit?: number;
|
|
92
115
|
enabled?: boolean;
|
|
116
|
+
courseId?: string | null;
|
|
117
|
+
cohortId?: string | null;
|
|
93
118
|
}) {
|
|
94
119
|
const { client, profileId } = useForge();
|
|
95
120
|
const from = options?.from?.toISOString();
|
|
96
121
|
const to = options?.to?.toISOString();
|
|
122
|
+
const courseId = options?.courseId || undefined;
|
|
123
|
+
const cohortId = options?.cohortId || undefined;
|
|
97
124
|
return useQuery<MyMembershipCall[]>({
|
|
98
125
|
// The WINDOW is part of the key: paging back a month is a different question
|
|
99
126
|
// with a different answer, and sharing one cache entry would show February's
|
|
100
|
-
// calls in March until a refetch landed.
|
|
101
|
-
|
|
127
|
+
// calls in March until a refetch landed. The SCOPE is in it for the same
|
|
128
|
+
// reason - the classroom's answer and the members calendar's answer are two
|
|
129
|
+
// different lists, and sharing one entry would put one course's sessions on
|
|
130
|
+
// another course's page for as long as the cache held.
|
|
131
|
+
queryKey: [
|
|
132
|
+
...membershipCallKeys.mine(profileId),
|
|
133
|
+
from ?? "now",
|
|
134
|
+
to ?? "open",
|
|
135
|
+
courseId ?? "any-course",
|
|
136
|
+
cohortId ?? "any-cohort",
|
|
137
|
+
],
|
|
102
138
|
queryFn: async () => {
|
|
103
|
-
const res = await client.get("/public/
|
|
104
|
-
params: { profileId, from, to, limit: options?.limit },
|
|
139
|
+
const res = await client.get("/public/scheduled-calls/mine", {
|
|
140
|
+
params: { profileId, from, to, limit: options?.limit, courseId, cohortId },
|
|
105
141
|
});
|
|
106
142
|
return res.data;
|
|
107
143
|
},
|
|
@@ -146,7 +182,7 @@ export function useMyMembershipCallRecordings(options?: { limit?: number; enable
|
|
|
146
182
|
return useQuery<MyMembershipCallRecording[]>({
|
|
147
183
|
queryKey: ["membership-calls", "recordings", profileId],
|
|
148
184
|
queryFn: async () => {
|
|
149
|
-
const res = await client.get("/public/
|
|
185
|
+
const res = await client.get("/public/scheduled-calls/mine/recordings", {
|
|
150
186
|
params: { profileId, limit: options?.limit },
|
|
151
187
|
});
|
|
152
188
|
return res.data;
|
|
@@ -160,3 +196,17 @@ export function useMyMembershipCallRecordings(options?: { limit?: number; enable
|
|
|
160
196
|
refetchInterval: 120_000,
|
|
161
197
|
});
|
|
162
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The honest names for the two hooks above.
|
|
202
|
+
*
|
|
203
|
+
* Aliases rather than renames: `useMyMembershipCalls` and its types are
|
|
204
|
+
* PUBLISHED exports that a code-built site may already import, and breaking
|
|
205
|
+
* those to fix a word would cost a site owner a build for nothing. New code
|
|
206
|
+
* should reach for these; the old names stay for as long as anything imports
|
|
207
|
+
* them.
|
|
208
|
+
*/
|
|
209
|
+
export const useMyScheduledCalls = useMyMembershipCalls;
|
|
210
|
+
export const useMyScheduledCallRecordings = useMyMembershipCallRecordings;
|
|
211
|
+
export type MyScheduledCall = MyMembershipCall;
|
|
212
|
+
export type MyScheduledCallRecording = MyMembershipCallRecording;
|
package/src/i18n/de.json
CHANGED
|
@@ -572,6 +572,7 @@
|
|
|
572
572
|
"forge.course_access.secure_prompt_lead": "Jeder mit diesem Link kann deinen Kurs öffnen. Erstelle ein Konto mit",
|
|
573
573
|
"forge.course_access.secure_prompt_tail": "damit er nur dir gehört.",
|
|
574
574
|
"forge.course_access.signed_in_as": "Angemeldet als",
|
|
575
|
+
"forge.course_calls.title": "Live-Calls",
|
|
575
576
|
"forge.course_checkout.aria_close": "Schließen",
|
|
576
577
|
"forge.course_checkout.back": "Zurück",
|
|
577
578
|
"forge.course_checkout.confirm_email_placeholder": "E-Mail bestätigen",
|
package/src/i18n/en.json
CHANGED
|
@@ -572,6 +572,7 @@
|
|
|
572
572
|
"forge.course_access.secure_prompt_lead": "Anyone with this link can open your course. Create an account with",
|
|
573
573
|
"forge.course_access.secure_prompt_tail": "to keep it to yourself.",
|
|
574
574
|
"forge.course_access.signed_in_as": "Signed in as",
|
|
575
|
+
"forge.course_calls.title": "Live calls",
|
|
575
576
|
"forge.course_checkout.aria_close": "Close",
|
|
576
577
|
"forge.course_checkout.back": "Back",
|
|
577
578
|
"forge.course_checkout.confirm_email_placeholder": "Confirm email",
|
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 = {
|
|
@@ -48,6 +48,17 @@ export interface UseMemberCallsOptions {
|
|
|
48
48
|
now?: Date;
|
|
49
49
|
/** Monday-first. The repo's `utils/recurrence` fixes the same convention. */
|
|
50
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;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
const dayKey = (date: Date): string => date.toLocaleDateString("en-CA");
|
|
@@ -90,6 +101,8 @@ export function useMemberCalls(options: UseMemberCallsOptions = {}) {
|
|
|
90
101
|
to: gridEnd,
|
|
91
102
|
limit: options.limit,
|
|
92
103
|
enabled: options.enabled,
|
|
104
|
+
courseId: options.courseId,
|
|
105
|
+
cohortId: options.cohortId,
|
|
93
106
|
});
|
|
94
107
|
|
|
95
108
|
const days: MemberCalendarDay[] = useMemo(() => {
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCohortPage } from "../../data/queries/useCohortPage";
|
|
2
2
|
import { useForgeT } from "../../i18n";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { CourseCallsSection } from "./CourseCallsSection";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
|
|
6
7
|
export interface CohortPageProps {
|
|
@@ -13,7 +14,7 @@ function formatDate(value: string | number | Date): string {
|
|
|
13
14
|
return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
/** Cohort landing page
|
|
17
|
+
/** Cohort landing page: structured cohort/course/curriculum data. Built on `useCohortPage`. */
|
|
17
18
|
export function CohortPage({ cohortId }: CohortPageProps) {
|
|
18
19
|
const theme = useForgeTheme();
|
|
19
20
|
const t = useForgeT();
|
|
@@ -79,6 +80,19 @@ export function CohortPage({ cohortId }: CohortPageProps) {
|
|
|
79
80
|
</div>
|
|
80
81
|
</section>
|
|
81
82
|
)}
|
|
83
|
+
|
|
84
|
+
{/* The cohort's live calls, joinable, for the student looking at them.
|
|
85
|
+
The structured `data.calls` above this page carries no join address
|
|
86
|
+
on purpose (the payload is anonymous), so the JOINABLE list is read
|
|
87
|
+
separately and only for a signed-in student. Scoped to this cohort
|
|
88
|
+
plus the course behind it: a session addressed to everyone who bought
|
|
89
|
+
the course belongs on a cohort student's schedule, a sibling cohort's
|
|
90
|
+
does not. */}
|
|
91
|
+
<CourseCallsSection
|
|
92
|
+
courseId={course?.id ?? null}
|
|
93
|
+
cohortId={cohort.id}
|
|
94
|
+
style={{ marginTop: 32 }}
|
|
95
|
+
/>
|
|
82
96
|
</div>
|
|
83
97
|
);
|
|
84
98
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
3
3
|
import { useCourseClassroom, type UseCourseClassroomOptions } from "../headless/course/useCourseClassroom";
|
|
4
|
+
import { CourseCallsSection } from "./CourseCallsSection";
|
|
4
5
|
import { Loading } from "./Loading";
|
|
5
6
|
import { useForgeT } from "../../i18n";
|
|
6
7
|
|
|
@@ -14,20 +15,20 @@ export interface CourseAccessProps extends UseCourseClassroomOptions {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* Enrolled course player
|
|
18
|
+
* Enrolled course player: module/lesson sidebar, video progress tracking, and
|
|
18
19
|
* the account-binding gate that stands in front of both. Built on the headless
|
|
19
20
|
* `useCourseClassroom`.
|
|
20
21
|
*
|
|
21
22
|
* The three states worth naming, because each one strands somebody if it is
|
|
22
23
|
* drawn as the others:
|
|
23
24
|
*
|
|
24
|
-
* - **401
|
|
25
|
+
* - **401**: a bound grant opened with no session. Almost always the buyer,
|
|
25
26
|
* on an old email link, on a device that never signed in. Gets a sign-in
|
|
26
27
|
* button that returns HERE, never a dead end.
|
|
27
|
-
* - **403
|
|
28
|
+
* - **403**: a bound grant opened as somebody else. Gets "this belongs to a
|
|
28
29
|
* different account" and a way OUT of the current session. Never the word
|
|
29
30
|
* "sign in", which they have already done.
|
|
30
|
-
* - **legacy
|
|
31
|
+
* - **legacy**: no `account_id`, so the link alone is the credential. Opens
|
|
31
32
|
* normally, with a dismissible note offering to put it behind an account.
|
|
32
33
|
*/
|
|
33
34
|
export function CourseAccess({
|
|
@@ -82,7 +83,7 @@ export function CourseAccess({
|
|
|
82
83
|
<p style={{ fontSize: 14, color: tokens.muted, lineHeight: 1.6 }}>{gate.body}</p>
|
|
83
84
|
|
|
84
85
|
{/* Naming the session they are in is what makes "wrong account"
|
|
85
|
-
actionable
|
|
86
|
+
actionable, otherwise the reader has no idea which of their
|
|
86
87
|
addresses they are currently signed in with. */}
|
|
87
88
|
{gate.kind === "wrong_account" && classroom.signedInEmail && (
|
|
88
89
|
<p style={{ fontSize: 13, color: tokens.muted }}>
|
|
@@ -127,7 +128,7 @@ export function CourseAccess({
|
|
|
127
128
|
...style,
|
|
128
129
|
}}
|
|
129
130
|
>
|
|
130
|
-
{/* Legacy grant, opened fine. An offer, not an obstacle
|
|
131
|
+
{/* Legacy grant, opened fine. An offer, not an obstacle: inline, muted,
|
|
131
132
|
dismissible, and never in front of the course the reader came for. */}
|
|
132
133
|
{securePrompt && (
|
|
133
134
|
<div
|
|
@@ -311,6 +312,18 @@ export function CourseAccess({
|
|
|
311
312
|
)}
|
|
312
313
|
</main>
|
|
313
314
|
</div>
|
|
315
|
+
|
|
316
|
+
{/* The student's live calls, where the student already is.
|
|
317
|
+
Scoped to THIS enrolment: `cohortId` is the grant's own cohort, so a
|
|
318
|
+
student holding two grants on one course opens two classrooms and each
|
|
319
|
+
shows only its own sessions. No membership href, so the empty state
|
|
320
|
+
does not sell a membership to a course buyer. */}
|
|
321
|
+
<CourseCallsSection
|
|
322
|
+
courseId={data.access.courseId}
|
|
323
|
+
cohortId={data.access.cohortId ?? null}
|
|
324
|
+
navigate={go}
|
|
325
|
+
style={{ marginTop: 32 }}
|
|
326
|
+
/>
|
|
314
327
|
</div>
|
|
315
328
|
);
|
|
316
329
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
5
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { MemberCallsCalendar } from "./members/MemberCallsCalendar";
|
|
7
|
+
|
|
8
|
+
export interface CourseCallsSectionProps {
|
|
9
|
+
/** The course this surface is standing on. The classroom reads it off the access row. */
|
|
10
|
+
courseId?: string | null;
|
|
11
|
+
/** The cohort this surface is standing on, when there is one. */
|
|
12
|
+
cohortId?: string | null;
|
|
13
|
+
/** Heading above the calendar. Defaults to the translated "Live calls". */
|
|
14
|
+
title?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Builds the href of the call itself, from its public id. The default is the
|
|
17
|
+
* generic call page, which is where both apps mount it.
|
|
18
|
+
*/
|
|
19
|
+
getCallHref?: (publicId: string) => string;
|
|
20
|
+
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
21
|
+
navigate?: (href: string) => void;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A student's live calls, on the page the student is already on.
|
|
28
|
+
*
|
|
29
|
+
* ## Why this is a wrapper and not a calendar
|
|
30
|
+
*
|
|
31
|
+
* `MemberCallsCalendar` is already call-kind agnostic: it renders whatever
|
|
32
|
+
* `listForAttendee` returns, and that resolves course and cohort audiences the
|
|
33
|
+
* same way it resolves membership tiers. A classroom-specific month grid would
|
|
34
|
+
* be a second implementation of the same month, the same open/closed rule and
|
|
35
|
+
* the same join address - which is the dual-surface trap this pillar has been
|
|
36
|
+
* caught by twice. So this file adds exactly two things and nothing else: the
|
|
37
|
+
* SCOPE, and the decision to leave the membership upsell out.
|
|
38
|
+
*
|
|
39
|
+
* ## No membership href, deliberately
|
|
40
|
+
*
|
|
41
|
+
* The members calendar's empty state offers "My membership", which is right
|
|
42
|
+
* where it stands. On a classroom it would sell a membership to somebody who
|
|
43
|
+
* bought a course, so nothing is passed and the button does not render. That is
|
|
44
|
+
* the default in `MemberCallsCalendar`, not a special case here.
|
|
45
|
+
*
|
|
46
|
+
* ## Scope
|
|
47
|
+
*
|
|
48
|
+
* `courseId` and `cohortId` are intersected server-side with the audience keys
|
|
49
|
+
* the caller actually holds, so they narrow and never unlock. Passing BOTH is
|
|
50
|
+
* the classroom's case and is not redundant: a cohort student's schedule is
|
|
51
|
+
* their cohort's sessions PLUS anything addressed to everyone who bought the
|
|
52
|
+
* course. A sibling cohort's sessions are excluded because its key is not in
|
|
53
|
+
* the intersection.
|
|
54
|
+
*/
|
|
55
|
+
export function CourseCallsSection({
|
|
56
|
+
courseId,
|
|
57
|
+
cohortId,
|
|
58
|
+
title,
|
|
59
|
+
getCallHref,
|
|
60
|
+
navigate,
|
|
61
|
+
className,
|
|
62
|
+
style,
|
|
63
|
+
}: CourseCallsSectionProps) {
|
|
64
|
+
const translate = useForgeT();
|
|
65
|
+
const t = useThemeTokens();
|
|
66
|
+
const { isAuthenticated, isInitialized } = usePublicAuth();
|
|
67
|
+
|
|
68
|
+
// Nothing to scope by means nothing to show. Rendering the calendar unscoped
|
|
69
|
+
// here would quietly put the viewer's WHOLE schedule - other courses, the
|
|
70
|
+
// membership AMA - on one course's page.
|
|
71
|
+
if (!courseId && !cohortId) return null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A seat is minted against an ACCOUNT, so there is nothing to show a reader we
|
|
75
|
+
* cannot identify: the cohort page is anonymous to the public, and a legacy
|
|
76
|
+
* course grant opens for anyone holding the link. Rendering the calendar
|
|
77
|
+
* anyway would fire an authenticated read, take a 401, and put "we could not
|
|
78
|
+
* load your calls" on a page that is working perfectly.
|
|
79
|
+
*
|
|
80
|
+
* `isInitialized` as well, because the session is restored in an effect - a
|
|
81
|
+
* render before it lands looks signed-out to every child.
|
|
82
|
+
*/
|
|
83
|
+
if (!isInitialized || !isAuthenticated) return null;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<section
|
|
87
|
+
className={className}
|
|
88
|
+
style={{ display: "flex", flexDirection: "column", gap: 12, color: t.text, fontFamily: t.fontFamily, ...style }}
|
|
89
|
+
>
|
|
90
|
+
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{title ?? translate("forge.course_calls.title")}</h2>
|
|
91
|
+
<MemberCallsCalendar
|
|
92
|
+
courseId={courseId}
|
|
93
|
+
cohortId={cohortId}
|
|
94
|
+
getCallHref={getCallHref}
|
|
95
|
+
navigate={navigate}
|
|
96
|
+
/>
|
|
97
|
+
</section>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -186,10 +186,20 @@ export function FieldInput({
|
|
|
186
186
|
case "yes_no":
|
|
187
187
|
control = (
|
|
188
188
|
<div style={{ display: "flex", gap: 16 }}>
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
{/* Stored value is lowercase "yes"/"no": edge conditions, quiz grading and
|
|
190
|
+
the automation trigger coercion all compare against that, not the label. */}
|
|
191
|
+
{[
|
|
192
|
+
{ optValue: "yes", optLabel: "Yes" },
|
|
193
|
+
{ optValue: "no", optLabel: "No" },
|
|
194
|
+
].map(({ optValue, optLabel }) => (
|
|
195
|
+
<label key={optValue} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
196
|
+
<input
|
|
197
|
+
type="radio"
|
|
198
|
+
name={field.id}
|
|
199
|
+
checked={value === optValue}
|
|
200
|
+
onChange={() => onChange(optValue)}
|
|
201
|
+
/>
|
|
202
|
+
{optLabel}
|
|
193
203
|
</label>
|
|
194
204
|
))}
|
|
195
205
|
</div>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
6
|
+
import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The section is a WRAPPER, so the mock goes under the whole chain
|
|
10
|
+
* (`MemberCallsCalendar` -> `useMemberCalls` -> `useMyMembershipCalls`) rather
|
|
11
|
+
* than over the calendar itself. That is the point of the file: what is under
|
|
12
|
+
* test is the wiring - which scope reaches the query, which empty state the
|
|
13
|
+
* student is shown, and which call id Join opens - not the month grid, which
|
|
14
|
+
* has its own spec.
|
|
15
|
+
*/
|
|
16
|
+
const rows: MyMembershipCall[] = [];
|
|
17
|
+
const useMyMembershipCalls = vi.fn();
|
|
18
|
+
|
|
19
|
+
vi.mock("../../../data/queries/useMembershipCalls", () => ({
|
|
20
|
+
useMyMembershipCalls: (options: unknown) => useMyMembershipCalls(options),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
let session = { isAuthenticated: true, isInitialized: true };
|
|
24
|
+
vi.mock("../../../contexts/PublicAuthContext", () => ({
|
|
25
|
+
usePublicAuth: () => session,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { CourseCallsSection } = await import("../CourseCallsSection");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A day inside the month the grid opens on.
|
|
32
|
+
*
|
|
33
|
+
* `useMemberCalls` lays out the six weeks around the REAL clock and buckets
|
|
34
|
+
* calls into them by the viewer's local day, so a call pinned to a fixed
|
|
35
|
+
* calendar date falls outside the grid the moment that month passes and every
|
|
36
|
+
* assertion here goes quietly green-then-red. Built from `now` instead.
|
|
37
|
+
*/
|
|
38
|
+
const at = (hour: number): Date => {
|
|
39
|
+
const now = new Date();
|
|
40
|
+
return new Date(now.getFullYear(), now.getMonth(), 15, hour, 0, 0);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const call = (over: Partial<MyMembershipCall> = {}): MyMembershipCall => ({
|
|
44
|
+
id: "call-1",
|
|
45
|
+
title: "Week 1 kickoff",
|
|
46
|
+
description: "Bring your questions.",
|
|
47
|
+
startsAt: at(17).toISOString(),
|
|
48
|
+
endsAt: at(18).toISOString(),
|
|
49
|
+
timezone: "America/New_York",
|
|
50
|
+
participation: "open",
|
|
51
|
+
role: "attendee",
|
|
52
|
+
audiences: [{ id: "a1", audienceType: "course_cohort", audienceId: "cohort-1", label: "March cohort" }],
|
|
53
|
+
opensAt: at(16).toISOString(),
|
|
54
|
+
closesAt: at(20).toISOString(),
|
|
55
|
+
isOpen: true,
|
|
56
|
+
callPublicId: "pub-week-1",
|
|
57
|
+
recording: null,
|
|
58
|
+
...over,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const mount = (props: Record<string, unknown> = {}, navigate = vi.fn()) => {
|
|
62
|
+
const view = render(
|
|
63
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111", background: "#fff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
64
|
+
<CourseCallsSection courseId="course-1" cohortId="cohort-1" navigate={navigate} {...props} />
|
|
65
|
+
</ForgeThemeProvider>,
|
|
66
|
+
);
|
|
67
|
+
return { ...view, navigate };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
rows.length = 0;
|
|
72
|
+
session = { isAuthenticated: true, isInitialized: true };
|
|
73
|
+
useMyMembershipCalls.mockReset();
|
|
74
|
+
useMyMembershipCalls.mockImplementation(() => ({
|
|
75
|
+
data: rows,
|
|
76
|
+
isLoading: false,
|
|
77
|
+
error: null,
|
|
78
|
+
refetch: vi.fn(),
|
|
79
|
+
}));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(cleanup);
|
|
83
|
+
|
|
84
|
+
describe("CourseCallsSection", () => {
|
|
85
|
+
it("renders the student's calls under a heading", () => {
|
|
86
|
+
rows.push(call());
|
|
87
|
+
const { getAllByText } = mount();
|
|
88
|
+
expect(getAllByText("Live calls").length).toBe(1);
|
|
89
|
+
// Desktop grid cell and mobile agenda row are both in the DOM; the
|
|
90
|
+
// calendar renders both faces and hides one with a media class.
|
|
91
|
+
expect(getAllByText("Week 1 kickoff").length).toBeGreaterThan(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("scopes the read to the course AND the cohort being viewed", () => {
|
|
95
|
+
rows.push(call());
|
|
96
|
+
mount();
|
|
97
|
+
const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string; cohortId?: string };
|
|
98
|
+
expect(options.courseId).toBe("course-1");
|
|
99
|
+
expect(options.cohortId).toBe("cohort-1");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("passes only the cohort when that is all the surface knows", () => {
|
|
103
|
+
mount({ courseId: null });
|
|
104
|
+
const options = useMyMembershipCalls.mock.calls[0]![0] as { courseId?: string | null; cohortId?: string };
|
|
105
|
+
expect(options.courseId).toBeNull();
|
|
106
|
+
expect(options.cohortId).toBe("cohort-1");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows the empty state with NO membership upsell on a course surface", () => {
|
|
110
|
+
const { getByText, queryByText } = mount();
|
|
111
|
+
expect(getByText("Nothing this month")).toBeTruthy();
|
|
112
|
+
// The whole reason this wrapper exists. `MemberCallsCalendar` renders the
|
|
113
|
+
// button only when it is handed a `membershipHref`, and a course-only buyer
|
|
114
|
+
// standing in their own classroom must not be sold a membership.
|
|
115
|
+
expect(queryByText("My membership")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("links Join to the call's own publicId, on the generic call page", () => {
|
|
119
|
+
rows.push(call({ callPublicId: "pub-week-1" }));
|
|
120
|
+
const { getAllByText, getByText, navigate } = mount();
|
|
121
|
+
fireEvent.click(getAllByText("Week 1 kickoff")[0]!);
|
|
122
|
+
fireEvent.click(getByText("Join call"));
|
|
123
|
+
expect(navigate).toHaveBeenCalledWith("/i/video-calls/pub-week-1");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("offers Watch for a past call whose recording is ready", () => {
|
|
127
|
+
rows.push(
|
|
128
|
+
call({
|
|
129
|
+
id: "call-2",
|
|
130
|
+
title: "Week 0 orientation",
|
|
131
|
+
isOpen: false,
|
|
132
|
+
recording: { id: "rec-1", status: "ready", durationSeconds: 3600, watchUrl: "https://cdn.test/rec.mp4" },
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
const { getAllByText, container } = mount();
|
|
136
|
+
fireEvent.click(getAllByText("Week 0 orientation")[0]!);
|
|
137
|
+
// Queried by href rather than by text: the label carries the duration in a
|
|
138
|
+
// second text node, so a text matcher would be matching the minutes too.
|
|
139
|
+
const watch = container.querySelector<HTMLAnchorElement>('a[href="https://cdn.test/rec.mp4"]');
|
|
140
|
+
expect(watch?.textContent).toContain("Watch recording");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders nothing for a reader with no session, rather than a failed read", () => {
|
|
144
|
+
// A legacy course grant opens for anyone holding the link, and the cohort
|
|
145
|
+
// page is anonymous to the public. The attendee endpoint would 401 them, and
|
|
146
|
+
// "we could not load your calls" on a page that is working perfectly is
|
|
147
|
+
// worse than no section at all.
|
|
148
|
+
session = { isAuthenticated: false, isInitialized: true };
|
|
149
|
+
const { container } = mount();
|
|
150
|
+
// Not `textContent`: `ForgeThemeProvider` writes a `:root` custom-property
|
|
151
|
+
// block into the tree, which is there whatever the section does.
|
|
152
|
+
expect(container.querySelector("section")).toBeNull();
|
|
153
|
+
expect(useMyMembershipCalls).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("renders nothing when there is no course or cohort to scope by", () => {
|
|
157
|
+
// Unscoped here would put the viewer's WHOLE schedule - other courses, the
|
|
158
|
+
// membership AMA - on one course's page.
|
|
159
|
+
const { container } = mount({ courseId: null, cohortId: null });
|
|
160
|
+
expect(container.querySelector("section")).toBeNull();
|
|
161
|
+
expect(useMyMembershipCalls).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -26,7 +26,16 @@ export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
|
|
|
26
26
|
getCallHref?: (publicId: string) => string;
|
|
27
27
|
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
28
28
|
navigate?: (href: string) => void;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Where a viewer with nothing at all is sent to see what a membership
|
|
31
|
+
* includes. Shown only in the empty state.
|
|
32
|
+
*
|
|
33
|
+
* OPTIONAL, and omitting it is how a non-membership surface drops the upsell.
|
|
34
|
+
* A course-only buyer standing in their own classroom with no session this
|
|
35
|
+
* month must not be sold a membership they did not come for, so
|
|
36
|
+
* `CourseCallsSection` passes nothing here. The members calendar passes its
|
|
37
|
+
* path and is unchanged.
|
|
38
|
+
*/
|
|
30
39
|
membershipHref?: string;
|
|
31
40
|
className?: string;
|
|
32
41
|
style?: CSSProperties;
|
|
@@ -85,6 +94,15 @@ const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
85
94
|
* computed from the same window the join enforces. A client re-deriving it from
|
|
86
95
|
* the clock would give two answers to one question, and the wrong one is the one
|
|
87
96
|
* the member acts on.
|
|
97
|
+
*
|
|
98
|
+
* ## It is not a MEMBERSHIP calendar, only a member-area one
|
|
99
|
+
*
|
|
100
|
+
* The rows come from `listForAttendee`, which resolves every audience kind, so
|
|
101
|
+
* this same grid renders a course's office hours and a cohort's week-3 session
|
|
102
|
+
* without knowing what either is. `courseId` / `cohortId` (through
|
|
103
|
+
* `UseMemberCallsOptions`) narrow it to one of them, which is what lets the
|
|
104
|
+
* classroom and the cohort page reuse this component instead of growing a
|
|
105
|
+
* second month grid. See `CourseCallsSection`.
|
|
88
106
|
*/
|
|
89
107
|
export function MemberCallsCalendar({
|
|
90
108
|
getCallHref = (publicId) => `/i/video-calls/${publicId}`,
|