@tribe-nest/forge 3.52.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.52.0",
3
+ "version": "3.53.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,42 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+
5
+ /**
6
+ * The courses this fan can open on this site.
7
+ *
8
+ * `GET /public/courses/mine` resolves a grant by account OR by verified email,
9
+ * so a buyer who never clicked the claim link still sees what they paid for.
10
+ * That matters more here than anywhere: before this, a purchased course was the
11
+ * one thing a fan could buy and then not find, and the creator's only remedy
12
+ * was re-sending a claim link by hand.
13
+ */
14
+ export type MyCourse = {
15
+ accessId: string;
16
+ courseId: string;
17
+ title: string;
18
+ description: string | null;
19
+ slug: string | null;
20
+ cohortId: string | null;
21
+ currentLessonId: string | null;
22
+ completedAt: string | null;
23
+ /** False when the claim link was never used. The course is theirs either way;
24
+ * the flag exists so the UI can reassure rather than stay silent. */
25
+ claimed: boolean;
26
+ purchasedAt: string;
27
+ };
28
+
29
+ export function useMyCourses({ accountId, enabled = true }: { accountId?: string; enabled?: boolean } = {}) {
30
+ const { client, profileId } = useForge();
31
+
32
+ return useQuery<MyCourse[]>({
33
+ // `accountId` is in the key so signing out and back in as someone else
34
+ // cannot serve the previous fan's courses from cache.
35
+ queryKey: ["my-courses", accountId, profileId],
36
+ enabled: Boolean(accountId) && enabled,
37
+ queryFn: async () => {
38
+ const res = await client.get("/public/courses/mine", { params: { profileId } });
39
+ return (res.data ?? []) as MyCourse[];
40
+ },
41
+ });
42
+ }
@@ -8,6 +8,8 @@ export interface WebsiteAgentConfig {
8
8
  allowAnonymous?: boolean;
9
9
  allowOrderLookup?: boolean;
10
10
  allowLeadCapture?: boolean;
11
+ /** Paths the widget must not appear on. "/blog/*" covers every child. */
12
+ excludedPaths?: string[];
11
13
  }
12
14
 
13
15
  /** Widget bootstrap config for the profile. When `enabled` is false the widget
package/src/i18n/de.json CHANGED
@@ -23,6 +23,11 @@
23
23
  "forge.account_dashboard.cancelling": "Wird storniert…",
24
24
  "forge.account_dashboard.close": "Schließen",
25
25
  "forge.account_dashboard.confirm_password": "Neues Passwort bestätigen",
26
+ "forge.account_dashboard.courses_completed": "Abgeschlossen",
27
+ "forge.account_dashboard.courses_continue": "Dort weitermachen, wo du aufgehört hast",
28
+ "forge.account_dashboard.courses_empty": "Noch nichts hier. Gekaufte Kurse erscheinen auf dieser Seite.",
29
+ "forge.account_dashboard.courses_start": "Starten",
30
+ "forge.account_dashboard.courses_title": "Deine Kurse",
26
31
  "forge.account_dashboard.current_password": "Aktuelles Passwort",
27
32
  "forge.account_dashboard.data_export_description": "Exportiere eine Kopie deiner persönlichen Daten.",
28
33
  "forge.account_dashboard.data_section_title": "Deine Daten",
@@ -78,6 +83,7 @@
78
83
  "forge.account_dashboard.sessions_title": "Meine Sessions",
79
84
  "forge.account_dashboard.tab_account": "Konto",
80
85
  "forge.account_dashboard.tab_bookings": "Buchungen",
86
+ "forge.account_dashboard.tab_courses": "Kurse",
81
87
  "forge.account_dashboard.tab_membership": "Mitgliedschaft",
82
88
  "forge.account_dashboard.tab_notifications": "Mitteilungen",
83
89
  "forge.account_dashboard.tab_orders": "Bestellungen",
@@ -1287,4 +1293,4 @@
1287
1293
  "forge.user_menu.create_account": "Konto erstellen",
1288
1294
  "forge.user_menu.log_in": "Anmelden",
1289
1295
  "forge.user_menu.log_out": "Abmelden"
1290
- }
1296
+ }
package/src/i18n/en.json CHANGED
@@ -23,6 +23,11 @@
23
23
  "forge.account_dashboard.cancelling": "Cancelling…",
24
24
  "forge.account_dashboard.close": "Close",
25
25
  "forge.account_dashboard.confirm_password": "Confirm new password",
26
+ "forge.account_dashboard.courses_completed": "Completed",
27
+ "forge.account_dashboard.courses_continue": "Continue where you left off",
28
+ "forge.account_dashboard.courses_empty": "Nothing here yet. Courses you buy appear on this page.",
29
+ "forge.account_dashboard.courses_start": "Start",
30
+ "forge.account_dashboard.courses_title": "Your courses",
26
31
  "forge.account_dashboard.current_password": "Current password",
27
32
  "forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
28
33
  "forge.account_dashboard.data_section_title": "Your Data",
@@ -78,6 +83,7 @@
78
83
  "forge.account_dashboard.sessions_title": "My Sessions",
79
84
  "forge.account_dashboard.tab_account": "Account",
80
85
  "forge.account_dashboard.tab_bookings": "Bookings",
86
+ "forge.account_dashboard.tab_courses": "Courses",
81
87
  "forge.account_dashboard.tab_membership": "Membership",
82
88
  "forge.account_dashboard.tab_notifications": "Notifications",
83
89
  "forge.account_dashboard.tab_orders": "Orders",
@@ -1287,4 +1293,4 @@
1287
1293
  "forge.user_menu.create_account": "Create account",
1288
1294
  "forge.user_menu.log_in": "Log in",
1289
1295
  "forge.user_menu.log_out": "Log out"
1290
- }
1296
+ }
package/src/index.ts CHANGED
@@ -121,6 +121,7 @@ export * from "./data/queries/useAccount";
121
121
  export * from "./data/queries/useMyTickets";
122
122
  export * from "./data/queries/usePassTransfers";
123
123
  export * from "./data/queries/useMyBookings";
124
+ export * from "./data/queries/useMyCourses";
124
125
  // The member's own call calendar. Beside `useMyBookings` and `useMyTickets`
125
126
  // because it answers the same shape of question: what have I got coming up
126
127
  // with this artist, and where do I go for it.
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isPathExcluded } from "../pathExclusion";
3
+
4
+ /**
5
+ * The client half of the exclusion rule. It has to agree with the server half
6
+ * (`apps/backend/src/services/public/agent/pathExclusion.ts`) case for case:
7
+ * the server decides what gets stored, the browser decides what gets hidden,
8
+ * and a disagreement shows the widget on a page the artist excluded.
9
+ */
10
+ describe("isPathExcluded", () => {
11
+ const patterns = ["/pricing", "/blog/*"];
12
+
13
+ it("shows the widget when nothing is excluded", () => {
14
+ expect(isPathExcluded("/pricing", [])).toBe(false);
15
+ expect(isPathExcluded("/pricing", undefined)).toBe(false);
16
+ });
17
+
18
+ it("hides it on an exactly named page", () => {
19
+ expect(isPathExcluded("/pricing", patterns)).toBe(true);
20
+ });
21
+
22
+ it("leaves a sibling that merely shares a prefix alone", () => {
23
+ expect(isPathExcluded("/pricing-guide", patterns)).toBe(false);
24
+ expect(isPathExcluded("/blogroll", patterns)).toBe(false);
25
+ });
26
+
27
+ it("hides it under a wildcard, index page included", () => {
28
+ expect(isPathExcluded("/blog", patterns)).toBe(true);
29
+ expect(isPathExcluded("/blog/hello", patterns)).toBe(true);
30
+ expect(isPathExcluded("/blog/2026/hello", patterns)).toBe(true);
31
+ });
32
+
33
+ it("ignores case, a trailing slash and a query string", () => {
34
+ expect(isPathExcluded("/Blog/Hello/", patterns)).toBe(true);
35
+ expect(isPathExcluded("/PRICING?ref=email", patterns)).toBe(true);
36
+ });
37
+
38
+ it("treats a bare /* as the whole site", () => {
39
+ expect(isPathExcluded("/", ["/*"])).toBe(true);
40
+ expect(isPathExcluded("/anything/deep", ["/*"])).toBe(true);
41
+ });
42
+
43
+ it("hides it on the home page only when '/' is listed", () => {
44
+ expect(isPathExcluded("/", ["/"])).toBe(true);
45
+ expect(isPathExcluded("/shop", ["/"])).toBe(false);
46
+ });
47
+
48
+ it("accepts a stored pattern the artist typed loosely", () => {
49
+ expect(isPathExcluded("/pricing", ["pricing"])).toBe(true);
50
+ expect(isPathExcluded("/blog/hello", ["/blog/"])).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, afterEach } from "vitest";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { useCurrentPath } from "../useCurrentPath";
5
+
6
+ /**
7
+ * The widget hides itself per page, so it has to notice a page change. Forge
8
+ * has no router, and a site's in-app link is a `pushState` that fires no event
9
+ * of its own - which is exactly the case a `popstate`-only listener misses, and
10
+ * would leave the widget showing on an excluded page for the rest of the visit.
11
+ */
12
+ describe("useCurrentPath", () => {
13
+ afterEach(() => {
14
+ window.history.pushState({}, "", "/");
15
+ });
16
+
17
+ it("reports the path the browser is on", () => {
18
+ window.history.pushState({}, "", "/pricing");
19
+ const { result } = renderHook(() => useCurrentPath());
20
+ expect(result.current).toBe("/pricing");
21
+ });
22
+
23
+ it("follows a pushState navigation, which is every in-app link", () => {
24
+ const { result } = renderHook(() => useCurrentPath());
25
+ act(() => {
26
+ window.history.pushState({}, "", "/blog/hello");
27
+ });
28
+ expect(result.current).toBe("/blog/hello");
29
+ });
30
+
31
+ it("follows a replaceState navigation too", () => {
32
+ const { result } = renderHook(() => useCurrentPath());
33
+ act(() => {
34
+ window.history.replaceState({}, "", "/shop");
35
+ });
36
+ expect(result.current).toBe("/shop");
37
+ });
38
+
39
+ it("ignores the query string, which is not part of the path", () => {
40
+ const { result } = renderHook(() => useCurrentPath());
41
+ act(() => {
42
+ window.history.pushState({}, "", "/shop?sort=new");
43
+ });
44
+ expect(result.current).toBe("/shop");
45
+ });
46
+
47
+ it("leaves history working for everyone else - the wrap is additive", () => {
48
+ renderHook(() => useCurrentPath());
49
+ act(() => {
50
+ window.history.pushState({}, "", "/one");
51
+ });
52
+ expect(window.location.pathname).toBe("/one");
53
+ });
54
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Client half of the concierge's "don't appear on these pages" rule.
3
+ *
4
+ * The artist stores paths in the admin; the widget hides itself when the page
5
+ * it is on matches one. Two forms:
6
+ * "/pricing" - that page only
7
+ * "/blog/*" - that page AND everything under it
8
+ *
9
+ * Matching is case-insensitive and ignores a trailing slash, so "/blog",
10
+ * "/Blog" and "/blog/" are one page here as they are to a visitor.
11
+ *
12
+ * This must stay in step with the server half,
13
+ * `apps/backend/src/services/public/agent/pathExclusion.ts`, which normalises
14
+ * what gets stored. Kept as a copy rather than an import because Forge ships to
15
+ * creator sites and cannot reach into the backend.
16
+ */
17
+
18
+ /** Reduce one stored pattern or page path to the form both sides compare. */
19
+ function normalize(raw: string): string {
20
+ let path = (raw || "").trim();
21
+ if (!path) return "";
22
+ const url = /^https?:\/\/[^/]+(\/.*)?$/i.exec(path);
23
+ if (url) path = url[1] || "/";
24
+ path = path.split("#")[0].split("?")[0];
25
+ if (!path.startsWith("/")) path = `/${path}`;
26
+ path = path.replace(/\/{2,}/g, "/");
27
+ const wildcard = path.endsWith("/*");
28
+ const base = wildcard ? path.slice(0, -2) : path;
29
+ const cleaned = base.length > 1 ? base.replace(/\/+$/, "") : base;
30
+ return wildcard ? `${cleaned}/*` : cleaned || "/";
31
+ }
32
+
33
+ /** Does `path` fall under any of `patterns`? */
34
+ export function isPathExcluded(path: string, patterns: readonly string[] | null | undefined): boolean {
35
+ if (!patterns || patterns.length === 0) return false;
36
+ const normalizedTarget = normalize(path);
37
+ const target = (
38
+ normalizedTarget.endsWith("/*") ? normalizedTarget.slice(0, -2) || "/" : normalizedTarget
39
+ ).toLowerCase();
40
+ for (const raw of patterns) {
41
+ const pattern = normalize(raw).toLowerCase();
42
+ if (!pattern) continue;
43
+ if (pattern.endsWith("/*")) {
44
+ const prefix = pattern.slice(0, -2);
45
+ // "/*" alone means the whole site.
46
+ if (prefix === "") return true;
47
+ if (target === prefix || target.startsWith(`${prefix}/`)) return true;
48
+ continue;
49
+ }
50
+ if (target === pattern) return true;
51
+ }
52
+ return false;
53
+ }
@@ -5,6 +5,8 @@ import {
5
5
  streamAgentMessage,
6
6
  useWebsiteAgentConfig,
7
7
  } from "../../../data/queries/useWebsiteAgent";
8
+ import { isPathExcluded } from "./pathExclusion";
9
+ import { useCurrentPath } from "./useCurrentPath";
8
10
 
9
11
  export interface AiAgentMessage {
10
12
  role: "user" | "assistant";
@@ -35,6 +37,7 @@ function getVisitorId(): string {
35
37
  export function useAiAgent() {
36
38
  const { apiUrl, profileId, token } = useForge();
37
39
  const config = useWebsiteAgentConfig();
40
+ const path = useCurrentPath();
38
41
 
39
42
  const [messages, setMessages] = useState<AiAgentMessage[]>([]);
40
43
  const [sending, setSending] = useState(false);
@@ -94,9 +97,15 @@ export function useAiAgent() {
94
97
  [apiUrl, profileId, token, visitorId, sending, ensureConversation],
95
98
  );
96
99
 
100
+ // The artist can keep the assistant off named pages. Checked here rather than
101
+ // in the widget so every UI built on this hook obeys it, including custom
102
+ // ones. SSR reports no path, so the widget stays hidden until the browser can
103
+ // say where it is - appearing and then vanishing would be worse.
104
+ const excluded = !path || isPathExcluded(path, config.data?.excludedPaths);
105
+
97
106
  return {
98
107
  config: config.data,
99
- isEnabled: !!config.data?.enabled,
108
+ isEnabled: !!config.data?.enabled && !excluded,
100
109
  loading: config.isLoading,
101
110
  messages,
102
111
  sending,
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * The browser's current path, kept up to date across client-side navigation.
5
+ *
6
+ * Forge has no router of its own and ships into sites that do, so it cannot
7
+ * subscribe to one. `popstate` alone misses a pushState navigation, which is
8
+ * every in-app link, so the two history methods are wrapped once per document
9
+ * to emit an event the hook listens for. The wrapping is additive: the original
10
+ * methods still run, and anything else that wrapped them keeps working.
11
+ *
12
+ * Returns "" during SSR, where there is no location to read.
13
+ */
14
+
15
+ const EVENT = "forge:locationchange";
16
+ let patched = false;
17
+
18
+ function patchHistoryOnce() {
19
+ if (patched || typeof window === "undefined") return;
20
+ patched = true;
21
+ for (const method of ["pushState", "replaceState"] as const) {
22
+ const original = window.history[method];
23
+ window.history[method] = function patchedMethod(this: History, ...args: Parameters<History[typeof method]>) {
24
+ const result = original.apply(this, args);
25
+ window.dispatchEvent(new Event(EVENT));
26
+ return result;
27
+ };
28
+ }
29
+ }
30
+
31
+ export function useCurrentPath(): string {
32
+ const [path, setPath] = useState(() => (typeof window === "undefined" ? "" : window.location.pathname));
33
+
34
+ useEffect(() => {
35
+ patchHistoryOnce();
36
+ const update = () => setPath(window.location.pathname);
37
+ // A navigation that happened between first render and this effect would
38
+ // otherwise be missed.
39
+ update();
40
+ window.addEventListener("popstate", update);
41
+ window.addEventListener(EVENT, update);
42
+ return () => {
43
+ window.removeEventListener("popstate", update);
44
+ window.removeEventListener(EVENT, update);
45
+ };
46
+ }, []);
47
+
48
+ return path;
49
+ }
@@ -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 visibleTabs = ACCOUNT_TABS.filter((key) => key !== "rentals" || hasRentals || tab === "rentals");
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. Appearance-only props let authors restyle it without touching
25
- * behavior. For custom UI, use the `useAiAgent()` hook instead.
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();