@tribe-nest/forge 3.50.0 → 3.53.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/useMyCourses.ts +42 -0
- package/src/data/queries/useWebsiteAgent.ts +2 -0
- package/src/i18n/de.json +52 -1
- package/src/i18n/en.json +52 -1
- package/src/index.ts +2 -0
- package/src/types/models.ts +20 -0
- package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
- package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
- package/src/ui/headless/agent/pathExclusion.ts +53 -0
- package/src/ui/headless/agent/useAiAgent.ts +10 -1
- package/src/ui/headless/agent/useCurrentPath.ts +49 -0
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/styled/AccountDashboard.tsx +102 -1
- package/src/ui/styled/AiAgentWidget.tsx +3 -2
- 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 +16 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -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/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 +2 -2
- 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,180 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useCohortPage } from "../../../data/queries/useCohortPage";
|
|
4
|
+
import { useCreateCohortEnrolment } from "../../../data/queries/useCohortEnrolment";
|
|
5
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
7
|
+
import { rememberBookingSecret } from "../../../utils/bookingSecret";
|
|
8
|
+
|
|
9
|
+
export type CohortCheckoutStep = "plan" | "details" | "payment";
|
|
10
|
+
|
|
11
|
+
export interface UseCohortCheckoutOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Stripe return URL for a PAID enrolment. Default
|
|
14
|
+
* `/i/cohorts/:cohortId/finalise?bookingId=...`.
|
|
15
|
+
*
|
|
16
|
+
* Put ONLY the bookingId in this URL. The booking's secret is stashed in
|
|
17
|
+
* sessionStorage by this hook and read back on the finalise page; putting it
|
|
18
|
+
* in the URL would hand it to history, referrers and shared links, which is
|
|
19
|
+
* the exact leak it exists to close.
|
|
20
|
+
*/
|
|
21
|
+
finalisePath?: (cohortId: string, bookingId: string) => string;
|
|
22
|
+
/** Host-owned navigation on FREE completion (e.g. router navigate). */
|
|
23
|
+
onComplete?: (info: { cohortId: string; bookingId: string }) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const errMessage = (e: unknown) =>
|
|
27
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
28
|
+
|
|
29
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Headless cohort purchase: pick a plan, give buyer details, pay.
|
|
33
|
+
*
|
|
34
|
+
* THREE STEPS, NOT TWO, and the extra one is the point. A self-paced course has
|
|
35
|
+
* one price so its checkout opens on buyer details. A cohort is priced from its
|
|
36
|
+
* pricing plans, and choosing between "pay in full" and "three instalments" is
|
|
37
|
+
* the decision the buyer came to make, so it comes first and is never guessed.
|
|
38
|
+
* The default plan is preselected; nothing is bought until they say so.
|
|
39
|
+
*
|
|
40
|
+
* NO COUPON FIELD, deliberately. The cohort checkout takes a `planId` and
|
|
41
|
+
* nothing else that touches money: the plan IS the price. That is what keeps
|
|
42
|
+
* instalments 2..n, charged months later by a cron, equal to what was
|
|
43
|
+
* advertised.
|
|
44
|
+
*/
|
|
45
|
+
export function useCohortCheckout(cohortId?: string, opts: UseCohortCheckoutOptions = {}) {
|
|
46
|
+
const { user } = usePublicAuth();
|
|
47
|
+
const { data: page, isLoading } = useCohortPage(cohortId);
|
|
48
|
+
const createEnrolment = useCreateCohortEnrolment(cohortId);
|
|
49
|
+
const flow = usePaymentFlow({ path: `/public/cohorts/${cohortId}/start-payment`, autoStart: false });
|
|
50
|
+
|
|
51
|
+
const plans = useMemo(() => page?.data?.pricingPlans ?? [], [page]);
|
|
52
|
+
|
|
53
|
+
const [step, setStep] = useState<CohortCheckoutStep>("plan");
|
|
54
|
+
const [planId, setPlanId] = useState<string | undefined>(undefined);
|
|
55
|
+
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
56
|
+
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
57
|
+
const [email, setEmail] = useState(user?.email ?? "");
|
|
58
|
+
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
59
|
+
const [returnUrl, setReturnUrl] = useState("");
|
|
60
|
+
const [error, setError] = useState<string | null>(null);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The per-booking credential from `createEnrolment`. A ref, not state, so it
|
|
64
|
+
* never triggers a render and never lands in a dependency array. The backend
|
|
65
|
+
* returns it once and cannot reissue it.
|
|
66
|
+
*/
|
|
67
|
+
const bookingSecretRef = useRef<string | undefined>(undefined);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The creator's own default wins, and the first plan is only a fallback for a
|
|
71
|
+
* cohort whose plans predate `isDefault`. Preselecting nothing would make the
|
|
72
|
+
* commonest case ("one plan, pay in full") an extra click for no decision.
|
|
73
|
+
*/
|
|
74
|
+
const selectedPlan = useMemo(() => {
|
|
75
|
+
if (planId) return plans.find((p) => p.id === planId);
|
|
76
|
+
return plans.find((p) => p.isDefault) ?? plans[0];
|
|
77
|
+
}, [plans, planId]);
|
|
78
|
+
|
|
79
|
+
const finalisePath = opts.finalisePath ?? ((id, bookingId) => `/i/cohorts/${id}/finalise?bookingId=${bookingId}`);
|
|
80
|
+
|
|
81
|
+
const choosePlan = (id: string) => {
|
|
82
|
+
setError(null);
|
|
83
|
+
setPlanId(id);
|
|
84
|
+
setStep("details");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const enrol = async () => {
|
|
88
|
+
setError(null);
|
|
89
|
+
if (!cohortId) return;
|
|
90
|
+
if (!selectedPlan) {
|
|
91
|
+
setError("Choose a plan before continuing.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!firstName.trim() || !lastName.trim() || !EMAIL.test(email)) {
|
|
95
|
+
setError("Please enter your name and a valid email.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const created = await createEnrolment.mutateAsync({
|
|
101
|
+
planId: selectedPlan.id,
|
|
102
|
+
email,
|
|
103
|
+
firstName,
|
|
104
|
+
lastName,
|
|
105
|
+
questionnaire,
|
|
106
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
107
|
+
});
|
|
108
|
+
bookingSecretRef.current = created.bookingSecret;
|
|
109
|
+
// Written the instant it arrives: this is the only time the server will
|
|
110
|
+
// ever hand it over, and the finalise page (post-redirect) has no other
|
|
111
|
+
// way to get it.
|
|
112
|
+
rememberBookingSecret(created.bookingId, created.bookingSecret);
|
|
113
|
+
|
|
114
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
115
|
+
const ru = `${origin}${finalisePath(cohortId, created.bookingId)}`;
|
|
116
|
+
setReturnUrl(ru);
|
|
117
|
+
|
|
118
|
+
// A zero-priced plan confirms on creation and the access email fires from
|
|
119
|
+
// the server, so there is nothing to pay and nothing to wait for.
|
|
120
|
+
if (created.isFree) {
|
|
121
|
+
if (opts.onComplete) opts.onComplete({ cohortId, bookingId: created.bookingId });
|
|
122
|
+
else if (typeof window !== "undefined") window.location.href = ru;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await flow.start({
|
|
127
|
+
bookingId: created.bookingId,
|
|
128
|
+
bookingSecret: bookingSecretRef.current,
|
|
129
|
+
returnUrl: ru,
|
|
130
|
+
});
|
|
131
|
+
if (result.provider && result.provider !== "stripe") return;
|
|
132
|
+
setStep("payment");
|
|
133
|
+
} catch (e) {
|
|
134
|
+
setError(errMessage(e));
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
cohort: page?.data?.cohort,
|
|
140
|
+
course: page?.data?.course,
|
|
141
|
+
isLoading,
|
|
142
|
+
plans,
|
|
143
|
+
selectedPlan,
|
|
144
|
+
planId: selectedPlan?.id,
|
|
145
|
+
choosePlan,
|
|
146
|
+
step,
|
|
147
|
+
setStep,
|
|
148
|
+
firstName,
|
|
149
|
+
setFirstName,
|
|
150
|
+
lastName,
|
|
151
|
+
setLastName,
|
|
152
|
+
email,
|
|
153
|
+
setEmail,
|
|
154
|
+
questionnaire,
|
|
155
|
+
setQuestionnaire,
|
|
156
|
+
enrol,
|
|
157
|
+
clientSecret: flow.clientSecret,
|
|
158
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
159
|
+
isPaystack: flow.isPaystack,
|
|
160
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
161
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
162
|
+
/** The whole plan. */
|
|
163
|
+
totalAmount: flow.result?.totalAmount ?? (selectedPlan ? Number(selectedPlan.totalAmount) : undefined),
|
|
164
|
+
/** What THIS charge takes: the first instalment, or the whole plan. */
|
|
165
|
+
installmentAmount: flow.result?.installmentAmount,
|
|
166
|
+
installmentCount: selectedPlan?.installmentCount ?? 1,
|
|
167
|
+
/**
|
|
168
|
+
* Stripe needs a second confirmation before instalment 1 on a multi-payment
|
|
169
|
+
* plan: the consent to be charged again off-session. Render it, or the
|
|
170
|
+
* later instalments have no payment method to hit.
|
|
171
|
+
*/
|
|
172
|
+
setupRequiresAction: flow.result?.setupRequiresAction ?? false,
|
|
173
|
+
setupClientSecret: flow.result?.setupClientSecret ?? null,
|
|
174
|
+
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
175
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
176
|
+
returnUrl,
|
|
177
|
+
isProcessing: createEnrolment.isPending || flow.isStarting,
|
|
178
|
+
error,
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -197,6 +197,13 @@ export {
|
|
|
197
197
|
export { ConfirmSubscription, type ConfirmSubscriptionProps } from "./styled/ConfirmSubscription";
|
|
198
198
|
export { LeadMagnet, type LeadMagnetProps } from "./styled/LeadMagnet";
|
|
199
199
|
export { CohortPage, type CohortPageProps } from "./styled/CohortPage";
|
|
200
|
+
export { CohortCheckout, type CohortCheckoutProps } from "./styled/CohortCheckout";
|
|
201
|
+
export { CohortConfirmation, type CohortConfirmationProps } from "./styled/CohortConfirmation";
|
|
202
|
+
export {
|
|
203
|
+
useCohortCheckout,
|
|
204
|
+
type UseCohortCheckoutOptions,
|
|
205
|
+
type CohortCheckoutStep,
|
|
206
|
+
} from "./headless/cohort/useCohortCheckout";
|
|
200
207
|
export { CourseAccess, type CourseAccessProps } from "./styled/CourseAccess";
|
|
201
208
|
// A student's live calls, scoped to the course or cohort the page is standing
|
|
202
209
|
// on. A thin wrapper over `MemberCallsCalendar`, not a second calendar.
|
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
useSavedPosts,
|
|
19
19
|
useNotificationPreferences,
|
|
20
20
|
useUpdateNotificationPreference,
|
|
21
|
+
useMyCourses,
|
|
21
22
|
useMyFilmRentals,
|
|
23
|
+
type MyCourse,
|
|
22
24
|
useCancelMembership,
|
|
23
25
|
useMembershipAccess,
|
|
24
26
|
useOpenBillingPortal,
|
|
@@ -62,6 +64,11 @@ export const ACCOUNT_TABS = [
|
|
|
62
64
|
// Drawn only when the fan actually holds a rental (see `visibleTabs`), so the
|
|
63
65
|
// tab does not appear on the account page of every site that never sells one.
|
|
64
66
|
"rentals",
|
|
67
|
+
// Courses this fan has bought. Beside the other purchase surfaces because it
|
|
68
|
+
// answers the same question they do, and it was the ONE purchase with no
|
|
69
|
+
// answer on this page: a creator's most expensive product, invisible once
|
|
70
|
+
// bought. Drawn only when they hold one (see `visibleTabs`).
|
|
71
|
+
"courses",
|
|
65
72
|
"saved",
|
|
66
73
|
"account",
|
|
67
74
|
"notifications",
|
|
@@ -79,6 +86,7 @@ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
|
|
|
79
86
|
waitlist: "forge.account_dashboard.tab_waitlist",
|
|
80
87
|
bookings: "forge.account_dashboard.tab_bookings",
|
|
81
88
|
rentals: "forge.account_dashboard.tab_rentals",
|
|
89
|
+
courses: "forge.account_dashboard.tab_courses",
|
|
82
90
|
saved: "forge.account_dashboard.tab_saved",
|
|
83
91
|
account: "forge.account_dashboard.tab_account",
|
|
84
92
|
notifications: "forge.account_dashboard.tab_notifications",
|
|
@@ -116,6 +124,17 @@ export interface AccountDashboardProps {
|
|
|
116
124
|
* full-page visit, which works on any site and is merely slower.
|
|
117
125
|
*/
|
|
118
126
|
onNavigateFilm?: (href: string) => void;
|
|
127
|
+
/**
|
|
128
|
+
* Open a course the fan owns.
|
|
129
|
+
*
|
|
130
|
+
* It must go to the CLASSROOM, keyed by `accessId`, never to the course's
|
|
131
|
+
* sales page. The sales page is where someone decides whether to buy, which
|
|
132
|
+
* is a dead end for a person who already owns it and came here to carry on.
|
|
133
|
+
*
|
|
134
|
+
* Left out, a course row is inert - so a host that draws the tab and omits
|
|
135
|
+
* this gives the fan something that looks clickable and does nothing.
|
|
136
|
+
*/
|
|
137
|
+
onNavigateCourse?: (course: MyCourse) => void;
|
|
119
138
|
/**
|
|
120
139
|
* Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
|
|
121
140
|
* (which honors the visitor's selected currency + tenant exchange rates), so the
|
|
@@ -183,6 +202,15 @@ interface TabContext {
|
|
|
183
202
|
onNavigateMembership?: () => void;
|
|
184
203
|
onNavigateCommunity?: () => void;
|
|
185
204
|
onNavigateFilm?: (href: string) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Open a course the fan owns. Host-supplied, like every other navigation
|
|
207
|
+
* target here, because the route shape belongs to the site not to Forge.
|
|
208
|
+
*
|
|
209
|
+
* It must go to the CLASSROOM (`accessId`), never to the course's sales page
|
|
210
|
+
* (`courseId`). The sales page is where someone decides whether to buy, which
|
|
211
|
+
* is a dead end for someone who already owns it and came here to carry on.
|
|
212
|
+
*/
|
|
213
|
+
onNavigateCourse?: (course: MyCourse) => void;
|
|
186
214
|
}
|
|
187
215
|
|
|
188
216
|
// ---- page -------------------------------------------------------------------
|
|
@@ -198,6 +226,7 @@ export function AccountDashboard({
|
|
|
198
226
|
onNavigateMembership,
|
|
199
227
|
onNavigateCommunity,
|
|
200
228
|
onNavigateFilm,
|
|
229
|
+
onNavigateCourse,
|
|
201
230
|
formatAmount,
|
|
202
231
|
formatDate,
|
|
203
232
|
currency,
|
|
@@ -219,6 +248,13 @@ export function AccountDashboard({
|
|
|
219
248
|
* react-query serves both from one request rather than two.
|
|
220
249
|
*/
|
|
221
250
|
const { data: rentals } = useMyFilmRentals(user?.id);
|
|
251
|
+
/**
|
|
252
|
+
* Resolved by account OR verified email on the server, so a buyer who never
|
|
253
|
+
* clicked the claim link still sees the course they paid for. Before that,
|
|
254
|
+
* this tab would have been empty for exactly the people who ask where their
|
|
255
|
+
* course went.
|
|
256
|
+
*/
|
|
257
|
+
const { data: courses } = useMyCourses({ accountId: user?.id });
|
|
222
258
|
|
|
223
259
|
const fmtAmount = useAmountFormatter(formatAmount);
|
|
224
260
|
const fmtDate = formatDate ?? defaultFormatDate;
|
|
@@ -232,6 +268,7 @@ export function AccountDashboard({
|
|
|
232
268
|
onNavigateMembership,
|
|
233
269
|
onNavigateCommunity,
|
|
234
270
|
onNavigateFilm,
|
|
271
|
+
onNavigateCourse,
|
|
235
272
|
};
|
|
236
273
|
|
|
237
274
|
/**
|
|
@@ -241,7 +278,12 @@ export function AccountDashboard({
|
|
|
241
278
|
* panel underneath it.
|
|
242
279
|
*/
|
|
243
280
|
const hasRentals = (rentals?.total ?? 0) > 0;
|
|
244
|
-
const
|
|
281
|
+
const hasCourses = (courses?.length ?? 0) > 0;
|
|
282
|
+
const visibleTabs = ACCOUNT_TABS.filter(
|
|
283
|
+
(key) =>
|
|
284
|
+
(key !== "rentals" || hasRentals || tab === "rentals") &&
|
|
285
|
+
(key !== "courses" || hasCourses || tab === "courses"),
|
|
286
|
+
);
|
|
245
287
|
|
|
246
288
|
if (!user) return <Loading fullPage />;
|
|
247
289
|
|
|
@@ -289,6 +331,7 @@ export function AccountDashboard({
|
|
|
289
331
|
{tab === "waitlist" && <WaitlistTab ctx={ctx} />}
|
|
290
332
|
{tab === "bookings" && <BookingsTab ctx={ctx} />}
|
|
291
333
|
{tab === "rentals" && <RentalsTab ctx={ctx} />}
|
|
334
|
+
{tab === "courses" && <CoursesTab ctx={ctx} courses={courses ?? []} />}
|
|
292
335
|
{tab === "saved" && <SavedTab />}
|
|
293
336
|
{tab === "account" && <AccountTab ctx={ctx} />}
|
|
294
337
|
{tab === "notifications" && <NotificationsTab />}
|
|
@@ -1357,6 +1400,64 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
|
|
|
1357
1400
|
);
|
|
1358
1401
|
}
|
|
1359
1402
|
|
|
1403
|
+
// ---- Courses tab ------------------------------------------------------------
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* What this fan has bought and can open, with a way in.
|
|
1407
|
+
*
|
|
1408
|
+
* `claimed: false` means they never used the claim link. The course is theirs
|
|
1409
|
+
* either way - the server resolves the grant by verified email - so the row says
|
|
1410
|
+
* so plainly rather than staying silent, because "did my purchase register?" is
|
|
1411
|
+
* the question this whole tab exists to stop someone asking.
|
|
1412
|
+
*/
|
|
1413
|
+
function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
|
|
1414
|
+
const { t, card } = useCardStyles();
|
|
1415
|
+
const tr = useForgeT();
|
|
1416
|
+
|
|
1417
|
+
return (
|
|
1418
|
+
<div style={card}>
|
|
1419
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
|
|
1420
|
+
{tr("forge.account_dashboard.courses_title")}
|
|
1421
|
+
</h2>
|
|
1422
|
+
|
|
1423
|
+
{courses.length === 0 ? (
|
|
1424
|
+
<p style={{ color: t.muted }}>{tr("forge.account_dashboard.courses_empty")}</p>
|
|
1425
|
+
) : (
|
|
1426
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
1427
|
+
{courses.map((course) => (
|
|
1428
|
+
<button
|
|
1429
|
+
key={course.accessId}
|
|
1430
|
+
type="button"
|
|
1431
|
+
onClick={() => ctx.onNavigateCourse?.(course)}
|
|
1432
|
+
style={{
|
|
1433
|
+
textAlign: "left",
|
|
1434
|
+
background: t.surface,
|
|
1435
|
+
border: `1px solid ${t.border}`,
|
|
1436
|
+
borderRadius: t.cornerRadius,
|
|
1437
|
+
padding: 16,
|
|
1438
|
+
cursor: ctx.onNavigateCourse ? "pointer" : "default",
|
|
1439
|
+
color: t.text,
|
|
1440
|
+
}}
|
|
1441
|
+
>
|
|
1442
|
+
<div style={{ fontWeight: 600 }}>{course.title}</div>
|
|
1443
|
+
{/* No description. It is rich text: as a string it shows its own
|
|
1444
|
+
markup, as HTML it drags a sales page's formatting into a list
|
|
1445
|
+
row. The row carries the title and the state instead. */}
|
|
1446
|
+
<div style={{ color: t.muted, fontSize: 12, marginTop: 8 }}>
|
|
1447
|
+
{course.completedAt
|
|
1448
|
+
? tr("forge.account_dashboard.courses_completed")
|
|
1449
|
+
: course.currentLessonId
|
|
1450
|
+
? tr("forge.account_dashboard.courses_continue")
|
|
1451
|
+
: tr("forge.account_dashboard.courses_start")}
|
|
1452
|
+
</div>
|
|
1453
|
+
</button>
|
|
1454
|
+
))}
|
|
1455
|
+
</div>
|
|
1456
|
+
)}
|
|
1457
|
+
</div>
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1360
1461
|
// ---- Orders tab -------------------------------------------------------------
|
|
1361
1462
|
|
|
1362
1463
|
function OrdersTab({ ctx }: { ctx: TabContext }) {
|
|
@@ -21,8 +21,9 @@ export interface AiAgentWidgetProps {
|
|
|
21
21
|
* own config for the site's profile, renders a fixed-position bubble + panel,
|
|
22
22
|
* and streams answers grounded in the artist's content + knowledge base. Render
|
|
23
23
|
* it once (e.g. in __root); it renders nothing when the agent is disabled for
|
|
24
|
-
* the profile
|
|
25
|
-
*
|
|
24
|
+
* the profile, or when the visitor is on a page the artist excluded.
|
|
25
|
+
* Appearance-only props let authors restyle it without touching behavior. For
|
|
26
|
+
* custom UI, use the `useAiAgent()` hook instead.
|
|
26
27
|
*/
|
|
27
28
|
export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
|
|
28
29
|
const t = useForgeT();
|
|
@@ -157,7 +157,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
|
|
|
157
157
|
|
|
158
158
|
{/* `rich-text` = shared baseline content styles (WYSIWYG with the editor),
|
|
159
159
|
bundled with Forge via the ./rich-text.css import above. */}
|
|
160
|
-
<div className="rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
|
|
160
|
+
<div className="fg:rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
|
|
161
161
|
</article>
|
|
162
162
|
);
|
|
163
163
|
}
|
|
@@ -124,7 +124,7 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
124
124
|
>
|
|
125
125
|
{/* Channel list: full-width on mobile, fixed rail on desktop */}
|
|
126
126
|
<aside
|
|
127
|
-
className={`${showSidebar ? "flex" : "hidden"} md:flex w-full md:w-[260px] flex-col`}
|
|
127
|
+
className={`${showSidebar ? "fg:flex" : "fg:hidden"} fg:md:flex fg:w-full fg:md:w-[260px] fg:flex-col`}
|
|
128
128
|
style={{ borderRight: border, overflowY: "auto" }}
|
|
129
129
|
>
|
|
130
130
|
<div
|
|
@@ -174,7 +174,7 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
174
174
|
</aside>
|
|
175
175
|
|
|
176
176
|
{/* Message panel */}
|
|
177
|
-
<section className={`${showSidebar ? "hidden" : "flex"} md:flex flex-1 flex-col min-w-0`}>
|
|
177
|
+
<section className={`${showSidebar ? "fg:hidden" : "fg:flex"} fg:md:flex fg:flex-1 fg:flex-col fg:min-w-0`}>
|
|
178
178
|
{room.selectedChannel ? (
|
|
179
179
|
<MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} />
|
|
180
180
|
) : (
|