@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 +1 -1
- package/src/data/queries/useMyCourses.ts +42 -0
- package/src/data/queries/useWebsiteAgent.ts +2 -0
- package/src/i18n/de.json +7 -1
- package/src/i18n/en.json +7 -1
- package/src/index.ts +1 -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/styled/AccountDashboard.tsx +102 -1
- package/src/ui/styled/AiAgentWidget.tsx +3 -2
package/package.json
CHANGED
|
@@ -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
|
|
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();
|