@tribe-nest/forge 3.48.0 → 3.52.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/useCohortEnrolment.ts +87 -0
- package/src/data/queries/useMembershipCalls.ts +58 -8
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +78 -5
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
- package/src/ui/index.ts +10 -0
- package/src/ui/styled/BlogPost.tsx +1 -1
- package/src/ui/styled/ChatRoom.tsx +2 -2
- package/src/ui/styled/Checkout.tsx +125 -125
- package/src/ui/styled/CoachingBooking.tsx +1 -1
- package/src/ui/styled/CoachingDetail.tsx +4 -4
- package/src/ui/styled/CohortCheckout.tsx +450 -0
- package/src/ui/styled/CohortConfirmation.tsx +259 -0
- package/src/ui/styled/CohortPage.tsx +31 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -4
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/PaystackPayButton.tsx +1 -1
- package/src/ui/styled/PodcastEpisode.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +4 -4
- package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
- package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
- package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
- package/src/ui/styled/forge-utilities.css +463 -731
- package/src/ui/styled/members/MemberCallsCalendar.tsx +21 -3
- package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
- package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
- package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
- package/src/ui/styled/musicLinkStyles.tsx +4 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { AlertTriangle, ArrowRight, CalendarDays, Mail } from "lucide-react";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
|
+
import { Loading } from "./Loading";
|
|
6
|
+
import {
|
|
7
|
+
ConfirmationStage,
|
|
8
|
+
ConfirmationCard,
|
|
9
|
+
CheckSeal,
|
|
10
|
+
WarnSeal,
|
|
11
|
+
Perforation,
|
|
12
|
+
ConfirmationRow,
|
|
13
|
+
ConfirmationLostCheckout,
|
|
14
|
+
alpha,
|
|
15
|
+
} from "./Confirmation";
|
|
16
|
+
import { useForgeT } from "../../i18n";
|
|
17
|
+
import { useCohortEnrolmentFinalize } from "../../data/queries/useCohortEnrolment";
|
|
18
|
+
import { useCohortPage } from "../../data/queries/useCohortPage";
|
|
19
|
+
import { useBookingSecret } from "../headless/booking/useBookingSecret";
|
|
20
|
+
|
|
21
|
+
export interface CohortConfirmationProps {
|
|
22
|
+
cohortId: string;
|
|
23
|
+
bookingId?: string;
|
|
24
|
+
/** Formats a monetary amount for display. Defaults to a plain USD-style `$0.00`. */
|
|
25
|
+
formatAmount?: (n: number, currency?: string) => string;
|
|
26
|
+
/** Where the "browse more" action links. */
|
|
27
|
+
explorePath?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Where "start over" goes when this browser has no credential for the
|
|
30
|
+
* booking, i.e. back to the cohort to enrol again. Defaults to
|
|
31
|
+
* `/i/cohorts/:cohortId`.
|
|
32
|
+
*/
|
|
33
|
+
restartPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The page a cohort buyer lands on after paying.
|
|
38
|
+
*
|
|
39
|
+
* The pair to `CohortCheckout`. Both had to exist together: the checkout's
|
|
40
|
+
* Stripe `return_url` points here, so shipping the buy control without this
|
|
41
|
+
* would have sent every paying buyer to a 404 holding a charge and no
|
|
42
|
+
* confirmation.
|
|
43
|
+
*
|
|
44
|
+
* The booking's credential is read from sessionStorage rather than the URL. It
|
|
45
|
+
* was stashed there by the checkout before the redirect, because a secret in a
|
|
46
|
+
* return URL reaches history, referrers and anything the buyer pastes.
|
|
47
|
+
*/
|
|
48
|
+
export function CohortConfirmation({
|
|
49
|
+
cohortId,
|
|
50
|
+
bookingId,
|
|
51
|
+
formatAmount,
|
|
52
|
+
explorePath = "/i/courses",
|
|
53
|
+
restartPath,
|
|
54
|
+
}: CohortConfirmationProps) {
|
|
55
|
+
const theme = useForgeTheme();
|
|
56
|
+
const t = useForgeT();
|
|
57
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
58
|
+
// `resolved` gates the finalise: storage can only be read on the client, so
|
|
59
|
+
// firing before then would send the call without a secret it actually has and
|
|
60
|
+
// get a 404.
|
|
61
|
+
const { secret, resolved, isMissing } = useBookingSecret(bookingId);
|
|
62
|
+
const { data, isLoading, error } = useCohortEnrolmentFinalize(
|
|
63
|
+
cohortId,
|
|
64
|
+
resolved ? bookingId : undefined,
|
|
65
|
+
secret ?? undefined,
|
|
66
|
+
);
|
|
67
|
+
const { data: page } = useCohortPage(cohortId);
|
|
68
|
+
|
|
69
|
+
const text = theme.colors.text;
|
|
70
|
+
const primary = theme.colors.primary;
|
|
71
|
+
const cohort = page?.data?.cohort;
|
|
72
|
+
const course = page?.data?.course;
|
|
73
|
+
|
|
74
|
+
if (!resolved || isLoading) {
|
|
75
|
+
return (
|
|
76
|
+
<ConfirmationStage>
|
|
77
|
+
<Loading label={t("forge.cohort_confirmation.loading")} />
|
|
78
|
+
</ConfirmationStage>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (error || !data) {
|
|
83
|
+
// A booking with no credential in THIS browser is indistinguishable
|
|
84
|
+
// server-side from a stranger holding a leaked id, so it 404s on purpose.
|
|
85
|
+
// Explain that rather than implying the enrolment never existed.
|
|
86
|
+
if (isMissing) {
|
|
87
|
+
return (
|
|
88
|
+
<ConfirmationStage>
|
|
89
|
+
<ConfirmationLostCheckout
|
|
90
|
+
title={t("forge.cohort_confirmation.lost_title")}
|
|
91
|
+
body={t("forge.cohort_confirmation.lost_body")}
|
|
92
|
+
restartPath={restartPath ?? `/i/cohorts/${cohortId}`}
|
|
93
|
+
restartLabel={t("forge.cohort_confirmation.lost_restart")}
|
|
94
|
+
/>
|
|
95
|
+
</ConfirmationStage>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<ConfirmationStage>
|
|
100
|
+
<p style={{ color: text }}>{t("forge.cohort_confirmation.not_found")}</p>
|
|
101
|
+
</ConfirmationStage>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const confirmed = data.status === "confirmed";
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<ConfirmationStage>
|
|
109
|
+
<ConfirmationCard>
|
|
110
|
+
<div style={{ padding: "38px 30px 26px", textAlign: "center" }}>
|
|
111
|
+
{confirmed ? <CheckSeal /> : <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />}
|
|
112
|
+
<p
|
|
113
|
+
className="cf-rise"
|
|
114
|
+
style={{
|
|
115
|
+
margin: "22px 0 6px",
|
|
116
|
+
fontSize: 12,
|
|
117
|
+
fontWeight: 700,
|
|
118
|
+
letterSpacing: "0.22em",
|
|
119
|
+
textTransform: "uppercase",
|
|
120
|
+
color: confirmed ? primary : "#f59e0b",
|
|
121
|
+
animationDelay: "0.15s",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{confirmed
|
|
125
|
+
? t("forge.cohort_confirmation.eyebrow_confirmed")
|
|
126
|
+
: t("forge.cohort_confirmation.eyebrow_action_needed")}
|
|
127
|
+
</p>
|
|
128
|
+
<h1
|
|
129
|
+
className="cf-rise"
|
|
130
|
+
style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}
|
|
131
|
+
>
|
|
132
|
+
{confirmed
|
|
133
|
+
? t("forge.cohort_confirmation.title_confirmed")
|
|
134
|
+
: t("forge.cohort_confirmation.title_incomplete")}
|
|
135
|
+
</h1>
|
|
136
|
+
<p
|
|
137
|
+
className="cf-rise"
|
|
138
|
+
style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}
|
|
139
|
+
>
|
|
140
|
+
{confirmed
|
|
141
|
+
? data.firstName
|
|
142
|
+
? t("forge.cohort_confirmation.body_confirmed", { first_name: data.firstName })
|
|
143
|
+
: t("forge.cohort_confirmation.body_confirmed_no_name")
|
|
144
|
+
: t("forge.cohort_confirmation.body_incomplete", { status: data.status })}
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{cohort && (
|
|
149
|
+
<div className="cf-rise" style={{ padding: "0 30px 24px", animationDelay: "0.36s" }}>
|
|
150
|
+
<div
|
|
151
|
+
style={{
|
|
152
|
+
display: "flex",
|
|
153
|
+
alignItems: "center",
|
|
154
|
+
gap: 12,
|
|
155
|
+
borderRadius: theme.cornerRadius,
|
|
156
|
+
border: `1px solid ${alpha(primary, 0.18)}`,
|
|
157
|
+
background: alpha(primary, 0.04),
|
|
158
|
+
padding: 12,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<div
|
|
162
|
+
style={{
|
|
163
|
+
width: 56,
|
|
164
|
+
height: 56,
|
|
165
|
+
borderRadius: theme.cornerRadius,
|
|
166
|
+
background: alpha(primary, 0.12),
|
|
167
|
+
display: "grid",
|
|
168
|
+
placeItems: "center",
|
|
169
|
+
flexShrink: 0,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<CalendarDays size={26} color={primary} />
|
|
173
|
+
</div>
|
|
174
|
+
<div style={{ minWidth: 0 }}>
|
|
175
|
+
<div style={{ fontSize: 16, fontWeight: 800 }}>{course?.title ?? cohort.title}</div>
|
|
176
|
+
<div style={{ fontSize: 13, color: alpha(text, 0.6) }}>{cohort.title}</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
<Perforation />
|
|
183
|
+
|
|
184
|
+
<div className="cf-rise" style={{ padding: "26px 30px 8px", animationDelay: "0.44s" }}>
|
|
185
|
+
{confirmed && (
|
|
186
|
+
<div
|
|
187
|
+
style={{
|
|
188
|
+
display: "flex",
|
|
189
|
+
alignItems: "flex-start",
|
|
190
|
+
gap: 10,
|
|
191
|
+
fontSize: 14,
|
|
192
|
+
color: alpha(text, 0.8),
|
|
193
|
+
background: alpha(primary, 0.05),
|
|
194
|
+
border: `1px solid ${alpha(primary, 0.16)}`,
|
|
195
|
+
borderRadius: theme.cornerRadius,
|
|
196
|
+
padding: "12px 14px",
|
|
197
|
+
marginBottom: 16,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<Mail size={16} color={primary} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
201
|
+
<span style={{ lineHeight: 1.5 }}>
|
|
202
|
+
{t("forge.cohort_confirmation.emailed", { email: data.email ?? "" })}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
|
|
207
|
+
<ConfirmationRow
|
|
208
|
+
label={t("forge.cohort_confirmation.row_student")}
|
|
209
|
+
value={`${data.firstName} ${data.lastName}`.trim() || t("forge.cohort_confirmation.not_given")}
|
|
210
|
+
/>
|
|
211
|
+
<ConfirmationRow
|
|
212
|
+
label={t("forge.cohort_confirmation.row_reference")}
|
|
213
|
+
value={`#${data.id.slice(0, 8).toUpperCase()}`}
|
|
214
|
+
mono
|
|
215
|
+
/>
|
|
216
|
+
{/*
|
|
217
|
+
WHAT WAS ACTUALLY TAKEN, which on an instalment plan is the first
|
|
218
|
+
payment and not the plan's total. Reading the booking's charged
|
|
219
|
+
amount rather than the plan keeps this honest either way.
|
|
220
|
+
*/}
|
|
221
|
+
<ConfirmationRow
|
|
222
|
+
label={t("forge.cohort_confirmation.row_paid")}
|
|
223
|
+
value={
|
|
224
|
+
Number(data.totalAmountInChargedCurrency ?? data.totalAmount) > 0
|
|
225
|
+
? data.totalAmountInChargedCurrency
|
|
226
|
+
? fmt(Number(data.totalAmountInChargedCurrency), data.chargedCurrency || undefined)
|
|
227
|
+
: fmt(Number(data.totalAmount))
|
|
228
|
+
: t("forge.cohort_confirmation.free")
|
|
229
|
+
}
|
|
230
|
+
accent
|
|
231
|
+
strong
|
|
232
|
+
/>
|
|
233
|
+
</dl>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="cf-rise" style={{ padding: "20px 30px 30px", animationDelay: "0.52s" }}>
|
|
237
|
+
<a
|
|
238
|
+
href={explorePath}
|
|
239
|
+
style={{
|
|
240
|
+
display: "inline-flex",
|
|
241
|
+
alignItems: "center",
|
|
242
|
+
justifyContent: "center",
|
|
243
|
+
gap: 8,
|
|
244
|
+
width: "100%",
|
|
245
|
+
padding: "13px 18px",
|
|
246
|
+
borderRadius: theme.cornerRadius,
|
|
247
|
+
background: primary,
|
|
248
|
+
color: readableTextOn(primary),
|
|
249
|
+
fontWeight: 700,
|
|
250
|
+
textDecoration: "none",
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{t("forge.cohort_confirmation.explore")} <ArrowRight size={16} />
|
|
254
|
+
</a>
|
|
255
|
+
</div>
|
|
256
|
+
</ConfirmationCard>
|
|
257
|
+
</ConfirmationStage>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { CohortCheckout } from "./CohortCheckout";
|
|
4
6
|
import { Loading } from "./Loading";
|
|
5
7
|
|
|
6
8
|
export interface CohortPageProps {
|
|
@@ -13,7 +15,7 @@ function formatDate(value: string | number | Date): string {
|
|
|
13
15
|
return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
/** Cohort landing page
|
|
18
|
+
/** Cohort landing page: structured cohort/course/curriculum data. Built on `useCohortPage`. */
|
|
17
19
|
export function CohortPage({ cohortId }: CohortPageProps) {
|
|
18
20
|
const theme = useForgeTheme();
|
|
19
21
|
const t = useForgeT();
|
|
@@ -41,6 +43,21 @@ export function CohortPage({ cohortId }: CohortPageProps) {
|
|
|
41
43
|
{(cohort.startDate || cohort.endDate) && <p style={{ opacity: 0.7, marginBottom: 16 }}>{dateLabel}</p>}
|
|
42
44
|
{cohort.description && <p style={{ marginBottom: 24, lineHeight: 1.6 }}>{cohort.description}</p>}
|
|
43
45
|
|
|
46
|
+
{/*
|
|
47
|
+
THE WAY IN. Until 2026-09-07 this page described a cohort and gave a
|
|
48
|
+
reader no way to buy a seat on it: the checkout existed on the backend
|
|
49
|
+
and nothing on any storefront called it, so a creator could build a
|
|
50
|
+
cohort, price it and publish it with no way to sell it.
|
|
51
|
+
|
|
52
|
+
It renders NOTHING when the cohort has no pricing plans, which is what
|
|
53
|
+
keeps a not-yet-priced cohort from showing a dead button, and nothing
|
|
54
|
+
for a student who is already enrolled would be wrong to see - the
|
|
55
|
+
backend refuses a second seat on the same cohort for the same email.
|
|
56
|
+
*/}
|
|
57
|
+
<div style={{ marginBottom: 32 }}>
|
|
58
|
+
<CohortCheckout cohortId={cohort.id} />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
44
61
|
{course && (
|
|
45
62
|
<section style={{ marginBottom: 24 }}>
|
|
46
63
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 6 }}>{course.title}</h2>
|
|
@@ -79,6 +96,19 @@ export function CohortPage({ cohortId }: CohortPageProps) {
|
|
|
79
96
|
</div>
|
|
80
97
|
</section>
|
|
81
98
|
)}
|
|
99
|
+
|
|
100
|
+
{/* The cohort's live calls, joinable, for the student looking at them.
|
|
101
|
+
The structured `data.calls` above this page carries no join address
|
|
102
|
+
on purpose (the payload is anonymous), so the JOINABLE list is read
|
|
103
|
+
separately and only for a signed-in student. Scoped to this cohort
|
|
104
|
+
plus the course behind it: a session addressed to everyone who bought
|
|
105
|
+
the course belongs on a cohort student's schedule, a sibling cohort's
|
|
106
|
+
does not. */}
|
|
107
|
+
<CourseCallsSection
|
|
108
|
+
courseId={course?.id ?? null}
|
|
109
|
+
cohortId={cohort.id}
|
|
110
|
+
style={{ marginTop: 32 }}
|
|
111
|
+
/>
|
|
82
112
|
</div>
|
|
83
113
|
);
|
|
84
114
|
}
|
|
@@ -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
|
+
}
|
|
@@ -62,7 +62,7 @@ export function CourseDetail({
|
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<div
|
|
65
|
-
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
65
|
+
className={`fg:pb-32 fg:md:pb-0${className ? ` ${className}` : ""}`}
|
|
66
66
|
style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
|
|
67
67
|
>
|
|
68
68
|
{introVideo ? (
|
|
@@ -81,8 +81,8 @@ export function CourseDetail({
|
|
|
81
81
|
)
|
|
82
82
|
)}
|
|
83
83
|
|
|
84
|
-
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
85
|
-
<div className="flex-1 min-w-0">
|
|
84
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row fg:md:items-start fg:gap-6 fg:mt-6">
|
|
85
|
+
<div className="fg:flex-1 fg:min-w-0">
|
|
86
86
|
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{course.title}</h1>
|
|
87
87
|
|
|
88
88
|
{course.description && (
|
|
@@ -127,7 +127,7 @@ export function CourseDetail({
|
|
|
127
127
|
</div>
|
|
128
128
|
|
|
129
129
|
<aside
|
|
130
|
-
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
130
|
+
className="fg:fixed fg:bottom-0 fg:left-0 fg:right-0 fg:z-40 fg:w-full fg:md:sticky fg:md:top-4 fg:md:bottom-auto fg:md:left-auto fg:md:right-auto fg:md:w-[300px] fg:md:shrink-0 fg:flex fg:flex-col fg:gap-3"
|
|
131
131
|
style={{
|
|
132
132
|
padding: 16,
|
|
133
133
|
border: `1px solid ${tokens.primary}80`,
|
|
@@ -81,7 +81,7 @@ export function EventDetail({
|
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
83
|
<div
|
|
84
|
-
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
84
|
+
className={`fg:pb-32 fg:md:pb-0${className ? ` ${className}` : ""}`}
|
|
85
85
|
style={{ maxWidth: 1040, margin: "0 auto", color: t.text, ...style }}
|
|
86
86
|
>
|
|
87
87
|
{cover && (
|
|
@@ -98,8 +98,8 @@ export function EventDetail({
|
|
|
98
98
|
/>
|
|
99
99
|
)}
|
|
100
100
|
|
|
101
|
-
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
102
|
-
<div className="flex-1 min-w-0">
|
|
101
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row fg:md:items-start fg:gap-6 fg:mt-6">
|
|
102
|
+
<div className="fg:flex-1 fg:min-w-0">
|
|
103
103
|
<div style={{ marginBottom: 20 }}>
|
|
104
104
|
<EventCountdown dateTime={event.dateTime} timezone={event.timezone} />
|
|
105
105
|
</div>
|
|
@@ -152,7 +152,7 @@ export function EventDetail({
|
|
|
152
152
|
which is the shape most presales are announced in. */}
|
|
153
153
|
{(event.tickets.length > 0 || presale.visible) && (
|
|
154
154
|
<aside
|
|
155
|
-
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
155
|
+
className="fg:fixed fg:bottom-0 fg:left-0 fg:right-0 fg:z-40 fg:w-full fg:md:sticky fg:md:top-4 fg:md:bottom-auto fg:md:left-auto fg:md:right-auto fg:md:w-[300px] fg:md:shrink-0 fg:flex fg:flex-col fg:gap-3"
|
|
156
156
|
style={{
|
|
157
157
|
padding: 16,
|
|
158
158
|
border: `1px solid ${t.primary}80`,
|
|
@@ -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>
|
|
@@ -51,7 +51,7 @@ export function PaystackPayButton({ onPay, amount, currency, label, disabled, cl
|
|
|
51
51
|
setIsOpening(false);
|
|
52
52
|
}
|
|
53
53
|
}}
|
|
54
|
-
className={className ?? "w-full px-4 py-3 font-semibold"}
|
|
54
|
+
className={className ?? "fg:w-full fg:px-4 fg:py-3 fg:font-semibold"}
|
|
55
55
|
style={{
|
|
56
56
|
backgroundColor: theme.colors.primary,
|
|
57
57
|
color: readableTextOn(theme.colors.primary),
|
|
@@ -99,7 +99,7 @@ export function PodcastEpisode({
|
|
|
99
99
|
{/* Show notes. `rich-text` = shared baseline content styles (WYSIWYG
|
|
100
100
|
with the editor), bundled with Forge via the ./rich-text.css import. */}
|
|
101
101
|
<div
|
|
102
|
-
className="rich-text"
|
|
102
|
+
className="fg:rich-text"
|
|
103
103
|
style={{ lineHeight: 1.7, marginTop: 8, borderTop: `1px solid ${border}`, paddingTop: 18 }}
|
|
104
104
|
dangerouslySetInnerHTML={{ __html: episode.content }}
|
|
105
105
|
/>
|
|
@@ -246,7 +246,7 @@ function StandardDetail({
|
|
|
246
246
|
return (
|
|
247
247
|
<>
|
|
248
248
|
<div
|
|
249
|
-
className={`grid grid-cols-1 md:grid-cols-2 gap-8${className ? ` ${className}` : ""}`}
|
|
249
|
+
className={`fg:grid fg:grid-cols-1 fg:md:grid-cols-2 fg:gap-8${className ? ` ${className}` : ""}`}
|
|
250
250
|
style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
|
|
251
251
|
>
|
|
252
252
|
{/* Gallery */}
|
|
@@ -469,7 +469,7 @@ function StandardDetail({
|
|
|
469
469
|
{/* Mobile sticky Add-to-cart (only when the inline one is scrolled past). */}
|
|
470
470
|
{showSticky && !isCartOpen && (
|
|
471
471
|
<div
|
|
472
|
-
className="md:hidden"
|
|
472
|
+
className="fg:md:hidden"
|
|
473
473
|
style={{
|
|
474
474
|
position: "fixed",
|
|
475
475
|
bottom: 0,
|
|
@@ -583,10 +583,10 @@ function MusicDetail({
|
|
|
583
583
|
<div style={{ border, borderRadius: t.cornerRadius }}>
|
|
584
584
|
{/* Header */}
|
|
585
585
|
<div style={{ padding: 24, borderBottom: border }}>
|
|
586
|
-
<div className="flex flex-col md:flex-row" style={{ gap: 24 }}>
|
|
586
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row" style={{ gap: 24 }}>
|
|
587
587
|
{/* Cover + play overlay */}
|
|
588
588
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
|
589
|
-
<div className="w-full md:w-64 md:h-64" style={{ aspectRatio: "1 / 1", borderRadius: t.cornerRadius, overflow: "hidden", background: t.primary }}>
|
|
589
|
+
<div className="fg:w-full fg:md:w-64 fg:md:h-64" style={{ aspectRatio: "1 / 1", borderRadius: t.cornerRadius, overflow: "hidden", background: t.primary }}>
|
|
590
590
|
{cover ? (
|
|
591
591
|
<img src={cover} alt={product.title} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />
|
|
592
592
|
) : (
|