@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.50.0",
3
+ "version": "3.53.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -17,7 +17,7 @@
17
17
  "src"
18
18
  ],
19
19
  "scripts": {
20
- "build:css": "tailwindcss -i tailwind.input.css -o src/ui/styled/forge-utilities.css && node scripts/wrapForgeCss.mjs",
20
+ "build:css": "tailwindcss -i tailwind.input.css -o src/ui/styled/forge-utilities.css",
21
21
  "prepack": "npm run build:css",
22
22
  "check-types": "tsc --noEmit",
23
23
  "test": "vitest run",
@@ -0,0 +1,87 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import type { CourseBooking } from "../../types/models";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+
5
+ /**
6
+ * Buying a seat on a cohort.
7
+ *
8
+ * A DIFFERENT CHECKOUT FROM THE SELF-PACED ONE, and not by preference. A cohort
9
+ * is priced from its `course_pricing_plans` rows, so the buyer picks a PLAN and
10
+ * the plan is the price: there is no amount, no currency and no coupon field on
11
+ * this body. `courses.price` is 0 on every cohort course, which is exactly why
12
+ * the self-paced endpoint refuses one outright rather than selling it for
13
+ * nothing.
14
+ *
15
+ * The plan also decides whether this is one payment or several. An instalment
16
+ * plan takes a second consent at `start-payment` ("charge me again when nobody
17
+ * is here"), because instalments 2..n are taken months later by a cron.
18
+ */
19
+
20
+ export type CreateCohortEnrolmentInput = {
21
+ planId: string;
22
+ email: string;
23
+ firstName: string;
24
+ lastName: string;
25
+ questionnaire?: unknown;
26
+ attributionRefId?: string;
27
+ browserSessionId?: string;
28
+ };
29
+
30
+ export type CreateCohortEnrolmentResult = {
31
+ bookingId: string;
32
+ /**
33
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
34
+ * Every later call on this booking must present it. Persist it the instant it
35
+ * arrives - see `utils/bookingSecret`.
36
+ */
37
+ bookingSecret?: string;
38
+ cohortId: string;
39
+ planId: string;
40
+ courseId: string;
41
+ totalAmount: number;
42
+ currency: string;
43
+ installmentCount: number;
44
+ /** What is charged TODAY. Equals `totalAmount` on a single-payment plan. */
45
+ firstPaymentAmount: number;
46
+ isFree: boolean;
47
+ };
48
+
49
+ /** Reserve a seat on a cohort against one pricing plan. */
50
+ export function useCreateCohortEnrolment(cohortId?: string) {
51
+ const { client, profileId } = useForge();
52
+
53
+ return useMutation<CreateCohortEnrolmentResult, unknown, CreateCohortEnrolmentInput>({
54
+ mutationFn: async (body) => {
55
+ const res = await client.post(`/public/cohorts/${cohortId}/enrollments`, {
56
+ profileId,
57
+ cohortId,
58
+ ...body,
59
+ });
60
+ return res.data;
61
+ },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Finalize a cohort enrolment (`/public/cohorts/:cohortId/finalize`).
67
+ *
68
+ * Same shape and same reasoning as the self-paced finalize: the confirmation
69
+ * page is reached through a provider redirect, so the caller reads the booking
70
+ * secret back out of `utils/bookingSecret` rather than off in-memory state.
71
+ */
72
+ export function useCohortEnrolmentFinalize(cohortId?: string, bookingId?: string, bookingSecret?: string) {
73
+ const { client, profileId } = useForge();
74
+
75
+ return useQuery<CourseBooking>({
76
+ queryKey: ["cohortEnrolmentStatus", profileId, bookingId],
77
+ queryFn: async () => {
78
+ const res = await client.post(`/public/cohorts/${cohortId}/finalize`, {
79
+ profileId,
80
+ bookingId,
81
+ bookingSecret,
82
+ });
83
+ return res.data;
84
+ },
85
+ enabled: !!profileId && !!client && !!cohortId && !!bookingId,
86
+ });
87
+ }
@@ -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",
@@ -529,6 +535,51 @@
529
535
  "forge.coaching_detail.about_title": "Über diese Session",
530
536
  "forge.coaching_detail.duration_minutes": "{minutes} Minuten",
531
537
  "forge.coaching_detail.not_found": "Coaching-Session nicht gefunden.",
538
+ "forge.cohort_checkout.aria_close": "Schließen",
539
+ "forge.cohort_checkout.authorise_future_payments": "Autorisiere die späteren Zahlungen dieses Plans.",
540
+ "forge.cohort_checkout.back": "Zurück",
541
+ "forge.cohort_checkout.confirm_leave": "Ohne Abschluss verlassen? Dein Platz ist noch nicht reserviert.",
542
+ "forge.cohort_checkout.continue_to_payment": "Weiter zur Zahlung",
543
+ "forge.cohort_checkout.details_subtitle": "Wir senden deinen Zugang an diese Adresse.",
544
+ "forge.cohort_checkout.details_title": "Deine Daten",
545
+ "forge.cohort_checkout.due_now": "Jetzt fällig",
546
+ "forge.cohort_checkout.email_placeholder": "E-Mail",
547
+ "forge.cohort_checkout.first_name_placeholder": "Vorname",
548
+ "forge.cohort_checkout.installment_note": "{count} Zahlungen insgesamt, zusammen {total}.",
549
+ "forge.cohort_checkout.last_name_placeholder": "Nachname",
550
+ "forge.cohort_checkout.no_plans": "Diese Kohorte ist noch nicht zur Anmeldung geöffnet.",
551
+ "forge.cohort_checkout.pay": "{amount} zahlen",
552
+ "forge.cohort_checkout.payment_not_configured": "Zahlung ist nicht konfiguriert.",
553
+ "forge.cohort_checkout.payment_subtitle": "Schließe deine Anmeldung ab, um der Kohorte beizutreten.",
554
+ "forge.cohort_checkout.payment_title": "Zahlung",
555
+ "forge.cohort_checkout.plan_in_full": "Auf einmal zahlen",
556
+ "forge.cohort_checkout.plan_installments": "{count} Zahlungen à {amount}, alle {days} Tage",
557
+ "forge.cohort_checkout.plan_subtitle": "Wähle, wie du zahlen möchtest.",
558
+ "forge.cohort_checkout.plan_title": "Plan wählen",
559
+ "forge.cohort_checkout.preparing_payment": "Zahlung wird vorbereitet...",
560
+ "forge.cohort_checkout.tax_added": "+ {amount} Steuer",
561
+ "forge.cohort_checkout.title": "Anmelden",
562
+ "forge.cohort_checkout.trigger": "Anmelden",
563
+ "forge.cohort_checkout.working": "Einen Moment...",
564
+ "forge.cohort_confirmation.body_confirmed": "Du bist dabei, {first_name}. Bis zum Start.",
565
+ "forge.cohort_confirmation.body_confirmed_no_name": "Du bist dabei. Bis zum Start.",
566
+ "forge.cohort_confirmation.body_incomplete": "Deine Anmeldung ist {status}. Von dir wird gerade nichts weiter gebraucht.",
567
+ "forge.cohort_confirmation.emailed": "Wir haben die Details an {email} geschickt.",
568
+ "forge.cohort_confirmation.explore": "Mehr entdecken",
569
+ "forge.cohort_confirmation.eyebrow_action_needed": "Aktion nötig",
570
+ "forge.cohort_confirmation.eyebrow_confirmed": "Angemeldet",
571
+ "forge.cohort_confirmation.free": "Kostenlos",
572
+ "forge.cohort_confirmation.loading": "Anmeldung wird bestätigt...",
573
+ "forge.cohort_confirmation.lost_body": "Dieser Browser kennt diese Anmeldung nicht, wir können sie hier nicht anzeigen. Falls du bezahlt hast, sieh in deinen E-Mails nach der Bestätigung.",
574
+ "forge.cohort_confirmation.lost_restart": "Zurück zur Kohorte",
575
+ "forge.cohort_confirmation.lost_title": "Wir können diese Anmeldung hier nicht öffnen",
576
+ "forge.cohort_confirmation.not_found": "Anmeldung nicht gefunden.",
577
+ "forge.cohort_confirmation.not_given": "Nicht angegeben",
578
+ "forge.cohort_confirmation.row_paid": "Heute gezahlt",
579
+ "forge.cohort_confirmation.row_reference": "Referenz",
580
+ "forge.cohort_confirmation.row_student": "Teilnehmer",
581
+ "forge.cohort_confirmation.title_confirmed": "Dein Platz ist sicher",
582
+ "forge.cohort_confirmation.title_incomplete": "Fast geschafft",
532
583
  "forge.cohort_page.curriculum_title": "Lehrplan",
533
584
  "forge.cohort_page.date_range": "{start} bis {end}",
534
585
  "forge.cohort_page.date_tbd": "Noch offen",
@@ -1242,4 +1293,4 @@
1242
1293
  "forge.user_menu.create_account": "Konto erstellen",
1243
1294
  "forge.user_menu.log_in": "Anmelden",
1244
1295
  "forge.user_menu.log_out": "Abmelden"
1245
- }
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",
@@ -529,6 +535,51 @@
529
535
  "forge.coaching_detail.about_title": "About this session",
530
536
  "forge.coaching_detail.duration_minutes": "{minutes} minutes",
531
537
  "forge.coaching_detail.not_found": "Coaching session not found.",
538
+ "forge.cohort_checkout.aria_close": "Close",
539
+ "forge.cohort_checkout.authorise_future_payments": "Authorise the later payments on this plan.",
540
+ "forge.cohort_checkout.back": "Back",
541
+ "forge.cohort_checkout.confirm_leave": "Leave without finishing? Your seat is not held yet.",
542
+ "forge.cohort_checkout.continue_to_payment": "Continue to payment",
543
+ "forge.cohort_checkout.details_subtitle": "We will send your access to this address.",
544
+ "forge.cohort_checkout.details_title": "Your details",
545
+ "forge.cohort_checkout.due_now": "Due now",
546
+ "forge.cohort_checkout.email_placeholder": "Email",
547
+ "forge.cohort_checkout.first_name_placeholder": "First name",
548
+ "forge.cohort_checkout.installment_note": "{count} payments in total, {total} altogether.",
549
+ "forge.cohort_checkout.last_name_placeholder": "Last name",
550
+ "forge.cohort_checkout.no_plans": "This cohort is not open for enrolment yet.",
551
+ "forge.cohort_checkout.pay": "Pay {amount}",
552
+ "forge.cohort_checkout.payment_not_configured": "Payment is not configured.",
553
+ "forge.cohort_checkout.payment_subtitle": "Complete your enrolment to join the cohort.",
554
+ "forge.cohort_checkout.payment_title": "Payment",
555
+ "forge.cohort_checkout.plan_in_full": "Pay in full",
556
+ "forge.cohort_checkout.plan_installments": "{count} payments of {amount}, every {days} days",
557
+ "forge.cohort_checkout.plan_subtitle": "Choose how you would like to pay.",
558
+ "forge.cohort_checkout.plan_title": "Choose a plan",
559
+ "forge.cohort_checkout.preparing_payment": "Preparing payment...",
560
+ "forge.cohort_checkout.tax_added": "+ {amount} tax",
561
+ "forge.cohort_checkout.title": "Enrol",
562
+ "forge.cohort_checkout.trigger": "Enrol",
563
+ "forge.cohort_checkout.working": "Working...",
564
+ "forge.cohort_confirmation.body_confirmed": "You're in, {first_name}. See you at the start.",
565
+ "forge.cohort_confirmation.body_confirmed_no_name": "You're in. See you at the start.",
566
+ "forge.cohort_confirmation.body_incomplete": "Your enrolment is {status}. Nothing else is needed from you right now.",
567
+ "forge.cohort_confirmation.emailed": "We've emailed the details to {email}.",
568
+ "forge.cohort_confirmation.explore": "Browse more",
569
+ "forge.cohort_confirmation.eyebrow_action_needed": "Action needed",
570
+ "forge.cohort_confirmation.eyebrow_confirmed": "Enrolled",
571
+ "forge.cohort_confirmation.free": "Free",
572
+ "forge.cohort_confirmation.loading": "Confirming your enrolment...",
573
+ "forge.cohort_confirmation.lost_body": "This browser has no record of that enrolment, so we cannot show it. If you paid, check your email for the confirmation.",
574
+ "forge.cohort_confirmation.lost_restart": "Back to the cohort",
575
+ "forge.cohort_confirmation.lost_title": "We cannot open this enrolment here",
576
+ "forge.cohort_confirmation.not_found": "Enrolment not found.",
577
+ "forge.cohort_confirmation.not_given": "Not given",
578
+ "forge.cohort_confirmation.row_paid": "Paid today",
579
+ "forge.cohort_confirmation.row_reference": "Reference",
580
+ "forge.cohort_confirmation.row_student": "Student",
581
+ "forge.cohort_confirmation.title_confirmed": "You have your seat",
582
+ "forge.cohort_confirmation.title_incomplete": "Almost there",
532
583
  "forge.cohort_page.curriculum_title": "Curriculum",
533
584
  "forge.cohort_page.date_range": "{start} to {end}",
534
585
  "forge.cohort_page.date_tbd": "TBD",
@@ -1242,4 +1293,4 @@
1242
1293
  "forge.user_menu.create_account": "Create account",
1243
1294
  "forge.user_menu.log_in": "Log in",
1244
1295
  "forge.user_menu.log_out": "Log out"
1245
- }
1296
+ }
package/src/index.ts CHANGED
@@ -109,6 +109,7 @@ export * from "./data/queries/useForms";
109
109
  export * from "./data/queries/useReplay";
110
110
  export * from "./data/queries/useCoachingAvailability";
111
111
  export * from "./data/queries/useCohortPage";
112
+ export * from "./data/queries/useCohortEnrolment";
112
113
  export * from "./data/queries/useCourseAccess";
113
114
  // Films and series: the catalogue, one title, the buyer's library, the rental
114
115
  // money legs, and the three playback-session calls. Everything a custom viewing
@@ -120,6 +121,7 @@ export * from "./data/queries/useAccount";
120
121
  export * from "./data/queries/useMyTickets";
121
122
  export * from "./data/queries/usePassTransfers";
122
123
  export * from "./data/queries/useMyBookings";
124
+ export * from "./data/queries/useMyCourses";
123
125
  // The member's own call calendar. Beside `useMyBookings` and `useMyTickets`
124
126
  // because it answers the same shape of question: what have I got coming up
125
127
  // with this artist, and where do I go for it.
@@ -101,6 +101,26 @@ export type PaymentStartResponse = {
101
101
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
102
102
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
103
103
  taxQuote?: PublicTaxQuote;
104
+ /**
105
+ * COHORT ENROLMENT ONLY: what THIS charge takes, when the plan is paid in
106
+ * instalments. `totalAmount` above stays the whole plan, so a checkout that
107
+ * shows only the total tells an instalment buyer they are about to be charged
108
+ * three times what they will be. Equal to `totalAmount` on a single payment.
109
+ */
110
+ installmentAmount?: number;
111
+ /** COHORT ENROLMENT ONLY: how many payments the chosen plan is split into. */
112
+ installmentCount?: number;
113
+ /**
114
+ * COHORT ENROLMENT ONLY: the SECOND consent, taken before instalment 1.
115
+ *
116
+ * Instalments 2..n are charged months later by a cron with nobody present, so
117
+ * the payer has to agree to be charged off-session. When Stripe needs that
118
+ * confirmed in the browser, `setupRequiresAction` is true and
119
+ * `setupClientSecret` is what confirms it. A checkout that ignores these takes
120
+ * the first payment and then silently fails every later one.
121
+ */
122
+ setupRequiresAction?: boolean;
123
+ setupClientSecret?: string | null;
104
124
  /**
105
125
  * When the reserved stock goes back on sale, ISO-8601 — the RESTARTED clock,
106
126
  * measured from the card form rather than from when the cart was assembled,
@@ -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
+ }