@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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCohortEnrolment.ts +87 -0
  3. package/src/data/queries/useMyCourses.ts +42 -0
  4. package/src/data/queries/useWebsiteAgent.ts +2 -0
  5. package/src/i18n/de.json +52 -1
  6. package/src/i18n/en.json +52 -1
  7. package/src/index.ts +2 -0
  8. package/src/types/models.ts +20 -0
  9. package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
  10. package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
  11. package/src/ui/headless/agent/pathExclusion.ts +53 -0
  12. package/src/ui/headless/agent/useAiAgent.ts +10 -1
  13. package/src/ui/headless/agent/useCurrentPath.ts +49 -0
  14. package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
  15. package/src/ui/index.ts +7 -0
  16. package/src/ui/styled/AccountDashboard.tsx +102 -1
  17. package/src/ui/styled/AiAgentWidget.tsx +3 -2
  18. package/src/ui/styled/BlogPost.tsx +1 -1
  19. package/src/ui/styled/ChatRoom.tsx +2 -2
  20. package/src/ui/styled/Checkout.tsx +125 -125
  21. package/src/ui/styled/CoachingBooking.tsx +1 -1
  22. package/src/ui/styled/CoachingDetail.tsx +4 -4
  23. package/src/ui/styled/CohortCheckout.tsx +450 -0
  24. package/src/ui/styled/CohortConfirmation.tsx +259 -0
  25. package/src/ui/styled/CohortPage.tsx +16 -0
  26. package/src/ui/styled/CourseDetail.tsx +4 -4
  27. package/src/ui/styled/EventDetail.tsx +4 -4
  28. package/src/ui/styled/PaystackPayButton.tsx +1 -1
  29. package/src/ui/styled/PodcastEpisode.tsx +1 -1
  30. package/src/ui/styled/ProductDetail.tsx +4 -4
  31. package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
  32. package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
  33. package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
  34. package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
  35. package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
  36. package/src/ui/styled/forge-utilities.css +463 -731
  37. package/src/ui/styled/members/MemberCallsCalendar.tsx +2 -2
  38. package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
  39. package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
  40. package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
  41. 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 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();
@@ -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
  ) : (