@tribe-nest/forge 3.52.0 → 3.54.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useEvents.ts +14 -0
  7. package/src/data/queries/useMyCourses.ts +42 -0
  8. package/src/data/queries/useMyTickets.ts +49 -0
  9. package/src/data/queries/usePaymentFlow.ts +8 -0
  10. package/src/data/queries/useWebsiteAgent.ts +2 -0
  11. package/src/i18n/de.json +47 -0
  12. package/src/i18n/en.json +47 -0
  13. package/src/i18n/index.ts +3 -2
  14. package/src/index.ts +23 -0
  15. package/src/provider/ForgeAppProvider.tsx +8 -1
  16. package/src/provider/ForgeProvider.tsx +3 -2
  17. package/src/provider/SiteConfigProvider.tsx +3 -2
  18. package/src/runtime/RemotePage.tsx +110 -0
  19. package/src/runtime/hostRuntime.ts +84 -0
  20. package/src/runtime/pages.spec.ts +41 -0
  21. package/src/runtime/pages.ts +183 -0
  22. package/src/runtime/pagesClient.ts +127 -0
  23. package/src/runtime/registry.spec.ts +45 -0
  24. package/src/runtime/registry.ts +102 -0
  25. package/src/server/jobs.ts +4 -1
  26. package/src/server/platformEvents.generated.ts +92 -0
  27. package/src/types/models.ts +58 -0
  28. package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
  29. package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
  30. package/src/ui/headless/agent/pathExclusion.ts +53 -0
  31. package/src/ui/headless/agent/useAiAgent.ts +10 -1
  32. package/src/ui/headless/agent/useCurrentPath.ts +49 -0
  33. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  35. package/src/ui/headless/event/ticketApproval.ts +109 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  37. package/src/ui/headless/index.ts +11 -0
  38. package/src/ui/index.ts +18 -0
  39. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  40. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  41. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  42. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  43. package/src/ui/shell/TribeNestApp.tsx +6 -0
  44. package/src/ui/styled/AccountDashboard.tsx +181 -14
  45. package/src/ui/styled/AiAgentWidget.tsx +3 -2
  46. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  47. package/src/ui/styled/EventConfirmation.tsx +106 -9
  48. package/src/ui/styled/EventTickets.tsx +107 -13
  49. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  50. package/src/ui/styled/forge-utilities.css +310 -0
  51. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  53. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,102 @@
1
+ import { createContext, type Context } from "react";
2
+
3
+ /**
4
+ * The runtime a built creator site shares with the central pages bundle.
5
+ *
6
+ * A site and the hosted `/i/*` pages are two separately built bundles that have
7
+ * to behave as ONE app. If each carried its own React, its own router and its
8
+ * own copies of Forge's contexts, a fan would hold two carts, be logged in on
9
+ * one half of the page and anonymous on the other, and hooks would throw the
10
+ * moment a bundled block tried to read a provider the host had mounted.
11
+ *
12
+ * So the host publishes its runtime on a global object and the bundle reads it
13
+ * rather than carrying its own. First writer wins, always: the site boots
14
+ * first, registers, and the bundle then finds what is already there.
15
+ *
16
+ * That rule is what makes a CONTEXT shareable, which is the part worth
17
+ * understanding. `createContext` called twice produces two unrelated objects,
18
+ * and a provider mounted from one is invisible to a consumer reading the other.
19
+ * `createSharedContext` gives both bundles the same object instead, so the
20
+ * host's `<CartProvider>` is the cart the bundle's `useCart()` reads.
21
+ *
22
+ * ## The contract
23
+ *
24
+ * What lives here is a contract between a site built at one moment and a bundle
25
+ * published later, so it is ADDITIVE FOR EVER. A key, once published under a
26
+ * contract, never changes meaning and is never removed. Adding one is safe:
27
+ * an older bundle simply does not ask for it. Removing or repurposing one is a
28
+ * contract bump (`rc1` to `rc2`), which leaves old sites on the old bundle
29
+ * until their next build.
30
+ *
31
+ * Nothing INSIDE the bundle is covered by this. Forge's UI blocks ship in the
32
+ * bundle precisely so a block fix needs no contract change and no rebuild.
33
+ */
34
+
35
+ /**
36
+ * Names the set of things the registry exposes. It rides in the bundle URL
37
+ * (`forge-pages/<contract>/latest`), so a site always asks for the bundle that
38
+ * matches the runtime it was built with.
39
+ */
40
+ export const PAGES_RUNTIME_CONTRACT = "rc1";
41
+
42
+ /**
43
+ * A single global, not a module-level variable. A module-level one would give
44
+ * each bundle its own copy, which is the exact failure this exists to prevent.
45
+ * The name is deliberately awkward to discourage anyone reading it directly.
46
+ */
47
+ const GLOBAL_KEY = "__tribenestPagesRuntime__";
48
+
49
+ type Registry = Record<string, unknown>;
50
+
51
+ function registry(): Registry {
52
+ const scope = globalThis as unknown as Record<string, Registry | undefined>;
53
+ const existing = scope[GLOBAL_KEY];
54
+ if (existing) return existing;
55
+ const created: Registry = {};
56
+ scope[GLOBAL_KEY] = created;
57
+ return created;
58
+ }
59
+
60
+ /**
61
+ * Publish a value under `key`, or return the one already published.
62
+ *
63
+ * `create` runs only when nobody has claimed the key yet, so the host's React
64
+ * and the host's QueryClient are the ones everybody ends up with, whichever
65
+ * bundle happens to call first. Registration is therefore safe to run more than
66
+ * once and in any order.
67
+ */
68
+ export function shareRuntimeValue<T>(key: string, create: () => T): T {
69
+ const reg = registry();
70
+ if (!(key in reg)) reg[key] = create();
71
+ return reg[key] as T;
72
+ }
73
+
74
+ /** Read a published value, or `undefined` when the host never published it. */
75
+ export function readRuntimeValue<T>(key: string): T | undefined {
76
+ return registry()[key] as T | undefined;
77
+ }
78
+
79
+ /**
80
+ * A React context that is the SAME object in every bundle that asks for it.
81
+ *
82
+ * Use this instead of `createContext` for anything a hosted page consumes.
83
+ * `defaultValue` is only used by the bundle that gets there first; the others
84
+ * receive that same context and its default, which is correct, since a context
85
+ * with two different defaults across bundles would be two contexts again.
86
+ */
87
+ export function createSharedContext<T>(key: string, defaultValue: T): Context<T> {
88
+ return shareRuntimeValue(`context:${key}`, () => createContext<T>(defaultValue));
89
+ }
90
+
91
+ /**
92
+ * Which of `required` the host never published.
93
+ *
94
+ * The bundle calls this BEFORE it renders anything. A missing key means the
95
+ * site was built against a runtime older than this bundle expects, and the
96
+ * honest response is the reload notice: rendering anyway produces a blank page
97
+ * or a thrown hook, and neither tells anyone what went wrong.
98
+ */
99
+ export function missingRuntimeKeys(required: readonly string[]): string[] {
100
+ const reg = registry();
101
+ return required.filter((key) => !(key in reg));
102
+ }
@@ -246,7 +246,10 @@ export async function handleAppJobRun(
246
246
  headers: { "content-type": "application/json" },
247
247
  body: JSON.stringify({ token }),
248
248
  });
249
- valid = (await res.json())?.valid === true;
249
+ // Typed rather than left to inference: under Cloudflare's types `json()`
250
+ // resolves to `{}`, so the property read does not compile in a Worker.
251
+ const verified = (await res.json()) as { valid?: boolean } | null;
252
+ valid = verified?.valid === true;
250
253
  } catch {
251
254
  valid = false;
252
255
  }
@@ -220,6 +220,94 @@ export type PlatformEventMap = {
220
220
  unitPrice: number | null;
221
221
  }>;
222
222
  };
223
+ /**
224
+ * Event ticket request approved: The organiser approved a ticket request; the sale settled and the tickets were issued.
225
+ *
226
+ * Requires the `events.read` grant.
227
+ */
228
+ "event_ticket_request.approved": {
229
+ orderId: string;
230
+ eventId: string | null;
231
+ status: string | null;
232
+ total: number | null;
233
+ currency: string | null;
234
+ buyer: {
235
+ contactId: string | null;
236
+ email: string | null;
237
+ name: string | null;
238
+ };
239
+ tickets: Array<{
240
+ ticketId: string | null;
241
+ quantity: number;
242
+ unitPrice: number | null;
243
+ }>;
244
+ };
245
+ /**
246
+ * Event ticket request declined: The organiser declined a ticket request; the hold was released.
247
+ *
248
+ * Requires the `events.read` grant.
249
+ */
250
+ "event_ticket_request.declined": {
251
+ orderId: string;
252
+ eventId: string | null;
253
+ status: string | null;
254
+ total: number | null;
255
+ currency: string | null;
256
+ buyer: {
257
+ contactId: string | null;
258
+ email: string | null;
259
+ name: string | null;
260
+ };
261
+ tickets: Array<{
262
+ ticketId: string | null;
263
+ quantity: number;
264
+ unitPrice: number | null;
265
+ }>;
266
+ };
267
+ /**
268
+ * Event ticket request expired: A ticket request lapsed undecided; the hold and the seats were released.
269
+ *
270
+ * Requires the `events.read` grant.
271
+ */
272
+ "event_ticket_request.expired": {
273
+ orderId: string;
274
+ eventId: string | null;
275
+ status: string | null;
276
+ total: number | null;
277
+ currency: string | null;
278
+ buyer: {
279
+ contactId: string | null;
280
+ email: string | null;
281
+ name: string | null;
282
+ };
283
+ tickets: Array<{
284
+ ticketId: string | null;
285
+ quantity: number;
286
+ unitPrice: number | null;
287
+ }>;
288
+ };
289
+ /**
290
+ * Event ticket requested: Someone requested tickets to an event that needs the organiser's approval. Their card is held, not charged.
291
+ *
292
+ * Requires the `events.read` grant.
293
+ */
294
+ "event_ticket_request.submitted": {
295
+ orderId: string;
296
+ eventId: string | null;
297
+ status: string | null;
298
+ total: number | null;
299
+ currency: string | null;
300
+ buyer: {
301
+ contactId: string | null;
302
+ email: string | null;
303
+ name: string | null;
304
+ };
305
+ tickets: Array<{
306
+ ticketId: string | null;
307
+ quantity: number;
308
+ unitPrice: number | null;
309
+ }>;
310
+ };
223
311
  /**
224
312
  * Membership tier archived: A membership tier was archived and can no longer be joined.
225
313
  *
@@ -403,6 +491,10 @@ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
403
491
  "email.clicked": "emails.read",
404
492
  "email.opened": "emails.read",
405
493
  "event_ticket_order.paid": "events.read",
494
+ "event_ticket_request.approved": "events.read",
495
+ "event_ticket_request.declined": "events.read",
496
+ "event_ticket_request.expired": "events.read",
497
+ "event_ticket_request.submitted": "events.read",
406
498
  "membership_tier.archived": "membership.tier_archived",
407
499
  "membership_tier.created": "membership.tier_created",
408
500
  "message.received": "contacts.read",
@@ -66,6 +66,12 @@ export type PublicTaxQuote = {
66
66
  * Stripe always populates `paymentSecret` and never the Paystack pair; Paystack
67
67
  * always populates `accessCode` + `checkoutUrl` and never `paymentSecret`.
68
68
  */
69
+ /** Whether a started charge is taken now or only once the organiser approves. */
70
+ export type PaymentSettlement = "immediate" | "deferred";
71
+
72
+ /** How a provider holds a deferred charge until the decision. */
73
+ export type DeferredChargeStrategy = "authorization" | "saved_instrument";
74
+
69
75
  export type PaymentStartResponse = {
70
76
  /**
71
77
  * STRIPE ONLY: the PaymentIntent client secret fed to Stripe Elements.
@@ -101,6 +107,19 @@ export type PaymentStartResponse = {
101
107
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
102
108
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
103
109
  taxQuote?: PublicTaxQuote;
110
+ /**
111
+ * EVENT TICKET REQUESTS: `"deferred"` when this charge is committed now and
112
+ * settled only if the organiser approves. Absent (or `"immediate"`) on every
113
+ * ordinary sale. Drives the "Authorize" wording on the pay button.
114
+ */
115
+ settlement?: PaymentSettlement;
116
+ /**
117
+ * How the provider defers the charge. `"authorization"` places a temporary
118
+ * hold the fan can see on their statement; `"saved_instrument"` stores the
119
+ * card and takes nothing until approval. Only meaningful with
120
+ * `settlement: "deferred"`.
121
+ */
122
+ deferredStrategy?: DeferredChargeStrategy | null;
104
123
  /**
105
124
  * COHORT ENROLMENT ONLY: what THIS charge takes, when the plan is paid in
106
125
  * instalments. `totalAmount` above stays the whole plan, so a checkout that
@@ -1145,6 +1164,12 @@ export interface IEvent {
1145
1164
  createdAt: string;
1146
1165
  updatedAt: string;
1147
1166
  tickets: ITicket[];
1167
+ /**
1168
+ * At least one tier on this event needs the organiser's approval. Optional
1169
+ * on the wire; derive it from `tickets` with `eventHasApprovalTiers` rather
1170
+ * than trusting its absence.
1171
+ */
1172
+ hasApprovalTiers?: boolean;
1148
1173
  media: IMedia[];
1149
1174
  slug: string;
1150
1175
  /**
@@ -1339,6 +1364,17 @@ export type ITicket = {
1339
1364
  * {@link PublicMembershipGate}.
1340
1365
  */
1341
1366
  membershipGate?: PublicMembershipGate | null;
1367
+ /**
1368
+ * The organiser decides who gets this tier (curated access, feature 3).
1369
+ *
1370
+ * A request is lodged instead of a sale: the card is authorised, not charged,
1371
+ * the seat is held, and the organiser has a window to approve or decline.
1372
+ * Sent to every client. A site on an older Forge shows such a tier with its
1373
+ * ordinary buy button; that is accepted until the site takes the update.
1374
+ */
1375
+ requiresApproval?: boolean;
1376
+ /** How long the organiser has to decide, in hours. `null` means the platform default. */
1377
+ approvalWindowHours?: number | null;
1342
1378
  };
1343
1379
 
1344
1380
  // ---- Invoices ----------------------------------------------------------------
@@ -1506,6 +1542,12 @@ export enum OrderStatus {
1506
1542
  Delivered = "delivered",
1507
1543
  Cancelled = "cancelled",
1508
1544
  Processed = "processed",
1545
+ /** Ticket request lodged: charge committed (authorised), seat held, nothing captured, no passes. */
1546
+ PendingApproval = "pending_approval",
1547
+ /** Transient: the organiser approved and settlement is in flight. */
1548
+ Approved = "approved",
1549
+ /** The organiser declined the request; the authorisation was released. */
1550
+ Declined = "declined",
1509
1551
  }
1510
1552
 
1511
1553
  export type IPublicOrderItem = {
@@ -1620,6 +1662,22 @@ export type ITicketOrder = {
1620
1662
  refundedAmountCents?: number | string | null;
1621
1663
  refundState?: "none" | "partial" | "full" | string | null;
1622
1664
  lastRefundedAt?: string | null;
1665
+ /**
1666
+ * Ticket-request columns (curated access, feature 3). All absent on an
1667
+ * ordinary sale and on an older API build.
1668
+ *
1669
+ * `approvalRequestExpiresAt` is the "decide by" instant a fan is shown while
1670
+ * the request is `pending_approval`. `approvalFailureReason` is set when a
1671
+ * request ended without a sale: `"expired"` on a `cancelled` row whose window
1672
+ * lapsed undecided, and the provider's reason on a `payment_failed` row the
1673
+ * organiser approved but could not settle. See `getTicketOrderOutcome` and
1674
+ * `ticketRequestFailureKind`.
1675
+ */
1676
+ approvalRequestedAt?: string | null;
1677
+ approvalRequestExpiresAt?: string | null;
1678
+ approvalDecidedAt?: string | null;
1679
+ approvalDecisionNote?: string | null;
1680
+ approvalFailureReason?: string | null;
1623
1681
  customerName: string;
1624
1682
  customerEmail: string;
1625
1683
  createdAt: string;
@@ -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
+ }