@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useEvents.ts +14 -0
- package/src/data/queries/useMyCourses.ts +42 -0
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useWebsiteAgent.ts +2 -0
- package/src/i18n/de.json +47 -0
- package/src/i18n/en.json +47 -0
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +23 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +92 -0
- package/src/types/models.ts +58 -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/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
- package/src/ui/payment/ForgeStripePayment.tsx +37 -3
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +181 -14
- package/src/ui/styled/AiAgentWidget.tsx +3 -2
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- 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
|
+
}
|
package/src/server/jobs.ts
CHANGED
|
@@ -246,7 +246,10 @@ export async function handleAppJobRun(
|
|
|
246
246
|
headers: { "content-type": "application/json" },
|
|
247
247
|
body: JSON.stringify({ token }),
|
|
248
248
|
});
|
|
249
|
-
|
|
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",
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
+
}
|