@tribe-nest/forge 3.65.0 → 3.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +17 -0
- package/src/i18n/en.json +17 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { WebPage } from "../../types";
|
|
2
|
-
import {
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { useForge, useOptionalForge } from "../../provider/ForgeProvider";
|
|
3
4
|
import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
|
|
4
|
-
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { keepPreviousData, QueryClientContext, useMutation, useQuery } from "@tanstack/react-query";
|
|
5
6
|
|
|
6
7
|
export interface SiteConfig {
|
|
7
|
-
/** The tenant's settlement currency (e.g. "USD")
|
|
8
|
+
/** The tenant's settlement currency (e.g. "USD"): the currency API amounts
|
|
8
9
|
* are in and payments are charged in. */
|
|
9
10
|
currency: string;
|
|
10
11
|
/** The language the tenant speaks ("en" | "de" today), from
|
|
@@ -16,7 +17,7 @@ export interface SiteConfig {
|
|
|
16
17
|
locale?: string;
|
|
17
18
|
/** Sales-tax display mode: true = listing prices already contain tax
|
|
18
19
|
* ("incl. VAT" style, EU/UK), false/absent = tax added at checkout (US).
|
|
19
|
-
* Display-only
|
|
20
|
+
* Display-only: checkout totals come from the authoritative taxQuote. */
|
|
20
21
|
pricesIncludeTax?: boolean;
|
|
21
22
|
metaPixelId: string | null;
|
|
22
23
|
stripeAccountId: string | null;
|
|
@@ -29,7 +30,7 @@ export interface SiteConfig {
|
|
|
29
30
|
/** Whether Altcha proof-of-work bot protection is active. Self-hosted, so it
|
|
30
31
|
* works on every tenant subdomain + custom domain with no per-domain config. */
|
|
31
32
|
altchaEnabled?: boolean;
|
|
32
|
-
/** The creator's site display name
|
|
33
|
+
/** The creator's site display name: the manifest `name` fallback when the
|
|
33
34
|
* PWA identity has no explicit name. */
|
|
34
35
|
siteName?: string | null;
|
|
35
36
|
/** The profile-level PWA identity (icons/name/screenshots) used to build the
|
|
@@ -38,10 +39,21 @@ export interface SiteConfig {
|
|
|
38
39
|
pwa?: PwaConfig | null;
|
|
39
40
|
/** Suppresses the "Powered by TribeNest" badge on released builds. Server-side
|
|
40
41
|
* on purpose: the tenant owns `__root.tsx`, so anything expressed as a prop is
|
|
41
|
-
* an opt-out. Absent/false today
|
|
42
|
+
* an opt-out. Absent/false today. This is the lever a white-label plan
|
|
42
43
|
* entitlement flips, and it works without waiting for a Forge bump to reach
|
|
43
44
|
* every site. */
|
|
44
45
|
hideTribeNestBadge?: boolean;
|
|
46
|
+
/** The privacy policy and terms of the brand the site is sold under
|
|
47
|
+
* (TribeNest's, or Mandatera's for a law firm). What a site with no policy of
|
|
48
|
+
* its own links to. Optional because an older backend sends no such field,
|
|
49
|
+
* and every reader then falls back to TribeNest's. */
|
|
50
|
+
legalUrls?: BrandLegalUrls | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The brand's hosted legal pages, from `site-config`. */
|
|
54
|
+
export interface BrandLegalUrls {
|
|
55
|
+
privacy: string;
|
|
56
|
+
terms: string;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
/** The PWA identity block on `site-config` (mirrors `pwaConfig` on the profile). */
|
|
@@ -58,13 +70,13 @@ export interface PwaConfig {
|
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* The tenant's mutable runtime config (settlement currency, Meta Pixel id,
|
|
61
|
-
* payment provider). Fetched at runtime
|
|
73
|
+
* payment provider). Fetched at runtime (NOT baked into the build) so a
|
|
62
74
|
* creator can change these in settings and the live site reflects them without
|
|
63
75
|
* a rebuild/redeploy.
|
|
64
76
|
*
|
|
65
77
|
* If a `<SiteConfigProvider>` seeded an SSR-fetched value (see `fetchSiteConfig`
|
|
66
78
|
* in `@tribe-nest/forge/server`), it's used as `initialData` so the config is
|
|
67
|
-
* present on the first render
|
|
79
|
+
* present on the first render: no client-fetch flash, no hydration mismatch.
|
|
68
80
|
* An explicit `initialData` override still wins.
|
|
69
81
|
*/
|
|
70
82
|
export function useSiteConfig(opts?: { initialData?: SiteConfig }) {
|
|
@@ -79,10 +91,29 @@ export function useSiteConfig(opts?: { initialData?: SiteConfig }) {
|
|
|
79
91
|
},
|
|
80
92
|
enabled: !!client && !!profileId,
|
|
81
93
|
initialData: opts?.initialData ?? ssrInitial,
|
|
82
|
-
staleTime: 1000 * 60 * 5, // 5 min
|
|
94
|
+
staleTime: 1000 * 60 * 5, // 5 min: settings change rarely; refetch keeps it fresh
|
|
83
95
|
});
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The brand's hosted legal pages (`site-config.legalUrls`), or `undefined` when
|
|
100
|
+
* they are not known yet.
|
|
101
|
+
*
|
|
102
|
+
* Safe outside a `<ForgeProvider>` (returns `undefined`), because the components
|
|
103
|
+
* that read it (`CookieConsent`, `MusicLinkPage`) are also rendered standalone.
|
|
104
|
+
* Reads the SSR-seeded config first, then whatever `useSiteConfig` has cached;
|
|
105
|
+
* it never starts a fetch of its own.
|
|
106
|
+
*/
|
|
107
|
+
export function useBrandLegalUrls(): BrandLegalUrls | undefined {
|
|
108
|
+
const ssrInitial = useInitialSiteConfig();
|
|
109
|
+
const forge = useOptionalForge();
|
|
110
|
+
const queryClient = useContext(QueryClientContext);
|
|
111
|
+
const cached = forge?.profileId
|
|
112
|
+
? queryClient?.getQueryData<SiteConfig>(["site-config", forge.profileId])
|
|
113
|
+
: undefined;
|
|
114
|
+
return cached?.legalUrls ?? ssrInitial?.legalUrls ?? undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
/**
|
|
87
118
|
* The store's tax display mode from site config: true = prices shown to the
|
|
88
119
|
* buyer already include tax (inclusive/EU display), false = tax is added at
|
|
@@ -96,7 +127,7 @@ export function usePricesIncludeTax(): boolean {
|
|
|
96
127
|
|
|
97
128
|
// Tax-aware price rendering primitive, re-exported here (a file the root barrel
|
|
98
129
|
// already star-exports) so `@tribe-nest/frontend-shared` and app code can import
|
|
99
|
-
// it from "@tribe-nest/forge". Its home is `ui/format/PriceDisplay
|
|
130
|
+
// it from "@tribe-nest/forge". Its home is `ui/format/PriceDisplay`, and the
|
|
100
131
|
// `forge/ui` barrel should export it from there as well.
|
|
101
132
|
export {
|
|
102
133
|
PriceDisplay,
|
|
@@ -118,7 +149,7 @@ export function useSubmitContactForm() {
|
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
/**
|
|
121
|
-
* The rendered web page for a pathname
|
|
152
|
+
* The rendered web page for a pathname: either the published site (by
|
|
122
153
|
* subdomain) or a preview (by token). Keeps the previous page during navigation
|
|
123
154
|
* to avoid a white flash.
|
|
124
155
|
*/
|
|
@@ -3,23 +3,33 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
3
3
|
|
|
4
4
|
export interface WebsiteAgentConfig {
|
|
5
5
|
enabled: boolean;
|
|
6
|
+
/** The agent that answers on this website. */
|
|
7
|
+
agentId?: string;
|
|
6
8
|
displayName?: string;
|
|
9
|
+
/** The agent's language, for example "en" or "de". */
|
|
10
|
+
language?: string;
|
|
7
11
|
greeting?: string | null;
|
|
8
12
|
allowAnonymous?: boolean;
|
|
9
13
|
allowOrderLookup?: boolean;
|
|
10
14
|
allowLeadCapture?: boolean;
|
|
11
15
|
/** Paths the widget must not appear on. "/blog/*" covers every child. */
|
|
12
16
|
excludedPaths?: string[];
|
|
17
|
+
/** The agent also takes calls from the browser: show "Call us". */
|
|
18
|
+
voiceEnabled?: boolean;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
/** Widget bootstrap config for the
|
|
16
|
-
* should render nothing.
|
|
21
|
+
/** Widget bootstrap config for the website. When `enabled` is false the widget
|
|
22
|
+
* should render nothing. `websiteId` picks the agent of THIS website, since a
|
|
23
|
+
* profile with several sites can give each its own; without it the backend
|
|
24
|
+
* answers with the profile's default agent. */
|
|
17
25
|
export function useWebsiteAgentConfig() {
|
|
18
|
-
const { client, profileId } = useForge();
|
|
26
|
+
const { client, profileId, websiteId } = useForge();
|
|
19
27
|
return useQuery<WebsiteAgentConfig>({
|
|
20
|
-
queryKey: ["website-agent-config", profileId],
|
|
28
|
+
queryKey: ["website-agent-config", profileId, websiteId ?? null],
|
|
21
29
|
queryFn: async () => {
|
|
22
|
-
const res = await client.get(`/public/agent/config`, {
|
|
30
|
+
const res = await client.get(`/public/agent/config`, {
|
|
31
|
+
params: { profileId, ...(websiteId ? { websiteId } : {}) },
|
|
32
|
+
});
|
|
23
33
|
return res.data;
|
|
24
34
|
},
|
|
25
35
|
enabled: !!profileId && !!client,
|
|
@@ -88,6 +98,7 @@ export async function createAgentConversation(params: {
|
|
|
88
98
|
apiUrl: string;
|
|
89
99
|
token?: string | null;
|
|
90
100
|
profileId: string;
|
|
101
|
+
websiteId?: string;
|
|
91
102
|
visitorId: string;
|
|
92
103
|
}): Promise<{ id: string | null; requiresLogin?: boolean }> {
|
|
93
104
|
const res = await fetch(`${params.apiUrl}/public/agent/conversations`, {
|
|
@@ -96,7 +107,126 @@ export async function createAgentConversation(params: {
|
|
|
96
107
|
"Content-Type": "application/json",
|
|
97
108
|
...(params.token ? { authorization: `Bearer ${params.token}` } : {}),
|
|
98
109
|
},
|
|
99
|
-
body: JSON.stringify({
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
profileId: params.profileId,
|
|
112
|
+
...(params.websiteId ? { websiteId: params.websiteId } : {}),
|
|
113
|
+
visitorId: params.visitorId,
|
|
114
|
+
}),
|
|
100
115
|
});
|
|
101
116
|
return res.json();
|
|
102
117
|
}
|
|
118
|
+
|
|
119
|
+
// "Call us": a call with the website's agent, from the browser.
|
|
120
|
+
|
|
121
|
+
/** Where the call's media room is, and how to come back to it. */
|
|
122
|
+
export interface AgentCallCredentials {
|
|
123
|
+
callId: string;
|
|
124
|
+
roomId: string;
|
|
125
|
+
mediaUrl: string;
|
|
126
|
+
token: string;
|
|
127
|
+
expiresAt: string;
|
|
128
|
+
/** This browser's seat in the room, reused on every reconnect. */
|
|
129
|
+
identity: string;
|
|
130
|
+
/** Proves this browser started the call: needed to rejoin or end it. */
|
|
131
|
+
callKey: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The bot check a call starts with. See `solveCallChallenge`. */
|
|
135
|
+
export interface AgentCallChallenge {
|
|
136
|
+
challenge: string;
|
|
137
|
+
difficulty: number;
|
|
138
|
+
expiresAt: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A refused call request, with the HTTP status that says why. */
|
|
142
|
+
export class AgentCallRequestError extends Error {
|
|
143
|
+
readonly status: number;
|
|
144
|
+
constructor(status: number, message: string) {
|
|
145
|
+
super(message);
|
|
146
|
+
this.name = "AgentCallRequestError";
|
|
147
|
+
this.status = status;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function postAgentCall<T>(url: string, body: Record<string, unknown>, keepalive = false): Promise<T> {
|
|
152
|
+
let res: Response;
|
|
153
|
+
try {
|
|
154
|
+
res = await fetch(url, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(body),
|
|
158
|
+
// Lets the request outlive the page, so leaving it still hangs up.
|
|
159
|
+
keepalive,
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new AgentCallRequestError(0, err instanceof Error ? err.message : String(err));
|
|
163
|
+
}
|
|
164
|
+
const data = (await res.json().catch(() => null)) as (T & { message?: string }) | null;
|
|
165
|
+
if (!res.ok) throw new AgentCallRequestError(res.status, data?.message || res.statusText || "Request failed");
|
|
166
|
+
return data as T;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const withWebsite = (websiteId?: string) => (websiteId ? { websiteId } : {});
|
|
170
|
+
|
|
171
|
+
/** Step 1: ask for a bot check. 404 when the website's agent takes no calls. */
|
|
172
|
+
export function requestAgentCallChallenge(params: {
|
|
173
|
+
apiUrl: string;
|
|
174
|
+
profileId: string;
|
|
175
|
+
websiteId?: string;
|
|
176
|
+
visitorId: string;
|
|
177
|
+
}): Promise<AgentCallChallenge> {
|
|
178
|
+
return postAgentCall(`${params.apiUrl}/public/agent/calls/challenge`, {
|
|
179
|
+
profileId: params.profileId,
|
|
180
|
+
...withWebsite(params.websiteId),
|
|
181
|
+
visitorId: params.visitorId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Step 2: start the call with the solved check. Refusals carry the status:
|
|
187
|
+
* 400 failed check, 429 over the limit, 404 not available, 409 the minutes or
|
|
188
|
+
* the lines are used up, 503 the call network is down.
|
|
189
|
+
*/
|
|
190
|
+
export function startAgentCall(params: {
|
|
191
|
+
apiUrl: string;
|
|
192
|
+
profileId: string;
|
|
193
|
+
websiteId?: string;
|
|
194
|
+
visitorId: string;
|
|
195
|
+
challenge: string;
|
|
196
|
+
solution: string;
|
|
197
|
+
}): Promise<AgentCallCredentials> {
|
|
198
|
+
return postAgentCall(`${params.apiUrl}/public/agent/calls`, {
|
|
199
|
+
profileId: params.profileId,
|
|
200
|
+
...withWebsite(params.websiteId),
|
|
201
|
+
visitorId: params.visitorId,
|
|
202
|
+
challenge: params.challenge,
|
|
203
|
+
solution: params.solution,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A fresh join ticket for a reconnect: a ticket expires in minutes. */
|
|
208
|
+
export function joinAgentCall(params: {
|
|
209
|
+
apiUrl: string;
|
|
210
|
+
callId: string;
|
|
211
|
+
callKey: string;
|
|
212
|
+
identity: string;
|
|
213
|
+
}): Promise<AgentCallCredentials> {
|
|
214
|
+
return postAgentCall(`${params.apiUrl}/public/agent/calls/${params.callId}/join`, {
|
|
215
|
+
callKey: params.callKey,
|
|
216
|
+
identity: params.identity,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Hangs up. `keepalive` for a page that is going away. */
|
|
221
|
+
export function endAgentCall(params: {
|
|
222
|
+
apiUrl: string;
|
|
223
|
+
callId: string;
|
|
224
|
+
callKey: string;
|
|
225
|
+
keepalive?: boolean;
|
|
226
|
+
}): Promise<{ ended: boolean }> {
|
|
227
|
+
return postAgentCall(
|
|
228
|
+
`${params.apiUrl}/public/agent/calls/${params.callId}/end`,
|
|
229
|
+
{ callKey: params.callKey },
|
|
230
|
+
params.keepalive,
|
|
231
|
+
);
|
|
232
|
+
}
|
package/src/i18n/de.json
CHANGED
|
@@ -185,8 +185,25 @@
|
|
|
185
185
|
"forge.account_dashboard.waitlist_ticket_line_other": "{title} · {quantity} Tickets",
|
|
186
186
|
"forge.account_dashboard.waitlists_empty": "Du stehst auf keiner Warteliste.",
|
|
187
187
|
"forge.account_dashboard.waitlists_title": "Meine Wartelisten",
|
|
188
|
+
"forge.ai_agent_call_room.connecting": "Verbindung wird hergestellt…",
|
|
189
|
+
"forge.ai_agent_call_room.listening": "{name} hört zu",
|
|
190
|
+
"forge.ai_agent_call_room.no_mic": "Wir konnten dein Mikrofon nicht verwenden. Erlaube es in deinem Browser und ruf dann erneut an.",
|
|
191
|
+
"forge.ai_agent_call_room.speaking": "{name} spricht",
|
|
192
|
+
"forge.ai_agent_widget.aria_call": "Ruf uns an",
|
|
188
193
|
"forge.ai_agent_widget.aria_close": "Schließen",
|
|
189
194
|
"forge.ai_agent_widget.aria_open": "Chat-Assistent öffnen",
|
|
195
|
+
"forge.ai_agent_widget.aria_show_call": "Anruf anzeigen",
|
|
196
|
+
"forge.ai_agent_widget.call_again": "Erneut anrufen",
|
|
197
|
+
"forge.ai_agent_widget.call_back_to_chat": "Zurück zum Chat",
|
|
198
|
+
"forge.ai_agent_widget.call_connecting": "Verbindung wird hergestellt…",
|
|
199
|
+
"forge.ai_agent_widget.call_ended": "Anruf beendet.",
|
|
200
|
+
"forge.ai_agent_widget.call_error_busy": "Alle unsere Leitungen sind belegt. Bitte versuch es in ein paar Minuten noch einmal.",
|
|
201
|
+
"forge.ai_agent_widget.call_error_check_failed": "Wir konnten den Anruf nicht starten. Bitte versuch es noch einmal.",
|
|
202
|
+
"forge.ai_agent_widget.call_error_failed": "Wir konnten deinen Anruf nicht verbinden. Bitte versuch es noch einmal.",
|
|
203
|
+
"forge.ai_agent_widget.call_error_too_many": "Du hast in letzter Zeit mehrmals angerufen. Bitte versuch es später noch einmal.",
|
|
204
|
+
"forge.ai_agent_widget.call_error_unavailable": "Anrufe sind gerade nicht möglich.",
|
|
205
|
+
"forge.ai_agent_widget.call_hang_up": "Auflegen",
|
|
206
|
+
"forge.ai_agent_widget.call_try_again": "Erneut versuchen",
|
|
190
207
|
"forge.ai_agent_widget.default_greeting": "Hi! Frag mich, was du willst.",
|
|
191
208
|
"forge.ai_agent_widget.default_title": "Assistent",
|
|
192
209
|
"forge.ai_agent_widget.input_placeholder": "Nachricht schreiben…",
|
package/src/i18n/en.json
CHANGED
|
@@ -185,8 +185,25 @@
|
|
|
185
185
|
"forge.account_dashboard.waitlist_ticket_line_other": "{title} · {quantity} tickets",
|
|
186
186
|
"forge.account_dashboard.waitlists_empty": "You're not on any waitlists.",
|
|
187
187
|
"forge.account_dashboard.waitlists_title": "My Waitlists",
|
|
188
|
+
"forge.ai_agent_call_room.connecting": "Connecting…",
|
|
189
|
+
"forge.ai_agent_call_room.listening": "{name} is listening",
|
|
190
|
+
"forge.ai_agent_call_room.no_mic": "We could not use your microphone. Allow it in your browser, then call again.",
|
|
191
|
+
"forge.ai_agent_call_room.speaking": "{name} is speaking",
|
|
192
|
+
"forge.ai_agent_widget.aria_call": "Call us",
|
|
188
193
|
"forge.ai_agent_widget.aria_close": "Close",
|
|
189
194
|
"forge.ai_agent_widget.aria_open": "Open chat assistant",
|
|
195
|
+
"forge.ai_agent_widget.aria_show_call": "Show the call",
|
|
196
|
+
"forge.ai_agent_widget.call_again": "Call again",
|
|
197
|
+
"forge.ai_agent_widget.call_back_to_chat": "Back to chat",
|
|
198
|
+
"forge.ai_agent_widget.call_connecting": "Connecting…",
|
|
199
|
+
"forge.ai_agent_widget.call_ended": "Call ended.",
|
|
200
|
+
"forge.ai_agent_widget.call_error_busy": "All our lines are busy. Please try again in a few minutes.",
|
|
201
|
+
"forge.ai_agent_widget.call_error_check_failed": "We could not start the call. Please try again.",
|
|
202
|
+
"forge.ai_agent_widget.call_error_failed": "We could not connect your call. Please try again.",
|
|
203
|
+
"forge.ai_agent_widget.call_error_too_many": "You have called several times recently. Please try again later.",
|
|
204
|
+
"forge.ai_agent_widget.call_error_unavailable": "Calls are not available right now.",
|
|
205
|
+
"forge.ai_agent_widget.call_hang_up": "Hang up",
|
|
206
|
+
"forge.ai_agent_widget.call_try_again": "Try again",
|
|
190
207
|
"forge.ai_agent_widget.default_greeting": "Hi! Ask me anything.",
|
|
191
208
|
"forge.ai_agent_widget.default_title": "Assistant",
|
|
192
209
|
"forge.ai_agent_widget.input_placeholder": "Type a message…",
|
|
@@ -55,7 +55,7 @@ export interface ForgeClientProviderProps {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Low-level tenant/client provider
|
|
58
|
+
* Low-level tenant/client provider: supplies the `useForge()` context (Axios
|
|
59
59
|
* client, tenant ids, token). Most sites use the all-in-one `<ForgeProvider>`
|
|
60
60
|
* (which wraps this plus query/content/auth/cart/audio/site-config/theme); reach
|
|
61
61
|
* for `ForgeClientProvider` only when composing the tree yourself.
|
|
@@ -126,6 +126,12 @@ export const ForgeClientProvider = ({
|
|
|
126
126
|
return <ForgeContext.Provider value={value}>{children}</ForgeContext.Provider>;
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The Forge context, or `null` outside a `<ForgeProvider>`. For components that
|
|
131
|
+
* also render standalone and only enrich themselves when a provider is present.
|
|
132
|
+
*/
|
|
133
|
+
export const useOptionalForge = (): ForgeContextValue | null => useContext(ForgeContext);
|
|
134
|
+
|
|
129
135
|
/** Access the Forge context. Throws if used outside a `<ForgeProvider>`. */
|
|
130
136
|
export const useForge = (): ForgeContextValue => {
|
|
131
137
|
const ctx = useContext(ForgeContext);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { leadingZeroBits, solveCallChallenge } from "../callChallenge";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The bot check in front of "Call us". The backend (`callGuard.ts`) accepts a
|
|
7
|
+
* solution when SHA-256 of `${challenge}:${solution}` starts with `difficulty`
|
|
8
|
+
* zero bits, so the thing worth pinning is that what the browser finds is what
|
|
9
|
+
* the server would accept, checked here with Node's own hash rather than the
|
|
10
|
+
* Web Crypto the solver uses.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function serverLeadingZeroBits(digest: Buffer): number {
|
|
14
|
+
let bits = 0;
|
|
15
|
+
for (const byte of digest) {
|
|
16
|
+
if (byte === 0) {
|
|
17
|
+
bits += 8;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
let b = byte;
|
|
21
|
+
while ((b & 0x80) === 0) {
|
|
22
|
+
bits++;
|
|
23
|
+
b <<= 1;
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
return bits;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("leadingZeroBits", () => {
|
|
31
|
+
it("counts whole zero bytes and the zero bits of the first non-zero byte", () => {
|
|
32
|
+
expect(leadingZeroBits(new Uint8Array([0xff]))).toBe(0);
|
|
33
|
+
expect(leadingZeroBits(new Uint8Array([0x01]))).toBe(7);
|
|
34
|
+
expect(leadingZeroBits(new Uint8Array([0x00, 0x80]))).toBe(8);
|
|
35
|
+
expect(leadingZeroBits(new Uint8Array([0x00, 0x00, 0x0f]))).toBe(20);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("counts every bit of an all-zero digest", () => {
|
|
39
|
+
expect(leadingZeroBits(new Uint8Array(4))).toBe(32);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("solveCallChallenge", () => {
|
|
44
|
+
it("finds a solution the server's check accepts, at the production difficulty", async () => {
|
|
45
|
+
const challenge = "v1.eyJhIjoxfQ.c2ln";
|
|
46
|
+
const solution = await solveCallChallenge(challenge, 16);
|
|
47
|
+
expect(solution).toMatch(/^\d{1,12}$/);
|
|
48
|
+
const digest = createHash("sha256").update(`${challenge}:${solution}`).digest();
|
|
49
|
+
expect(serverLeadingZeroBits(digest)).toBeGreaterThanOrEqual(16);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the SMALLEST solution, so a batch boundary changes nothing", async () => {
|
|
53
|
+
const challenge = "small-batches";
|
|
54
|
+
const a = await solveCallChallenge(challenge, 10, { batchSize: 7, yieldToPage: async () => undefined });
|
|
55
|
+
const b = await solveCallChallenge(challenge, 10, { batchSize: 1000 });
|
|
56
|
+
expect(a).toBe(b);
|
|
57
|
+
for (let n = 0; n < Number(a); n++) {
|
|
58
|
+
const digest = createHash("sha256").update(`${challenge}:${n}`).digest();
|
|
59
|
+
expect(serverLeadingZeroBits(digest)).toBeLessThan(10);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("gives the page a turn after every batch it did not solve in", async () => {
|
|
64
|
+
const solution = Number(await solveCallChallenge("yield-check", 12, { batchSize: 64 }));
|
|
65
|
+
let yields = 0;
|
|
66
|
+
await solveCallChallenge("yield-check", 12, {
|
|
67
|
+
batchSize: 64,
|
|
68
|
+
yieldToPage: async () => {
|
|
69
|
+
yields++;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(yields).toBe(Math.floor(solution / 64));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("stops when the call is hung up while it works", async () => {
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const solving = solveCallChallenge("abort-me", 40, {
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
yieldToPage: async () => {
|
|
80
|
+
controller.abort();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
await expect(solving).rejects.toMatchObject({ name: "AbortError" });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
|
|
6
|
+
import { useAgentCall } from "../useAgentCall";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* "Call us", headless. Every call costs the business by the minute, so what
|
|
10
|
+
* is pinned here is the money-shaped part: the request sequence that starts a
|
|
11
|
+
* call, that a reconnect asks for a FRESH ticket with the identity the start
|
|
12
|
+
* gave, and that every way out (hang up, hang up mid-setup, leaving the page,
|
|
13
|
+
* unmounting) ends the call on the server.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const API = "https://api.test";
|
|
17
|
+
const PROFILE = "11111111-1111-4111-8111-111111111111";
|
|
18
|
+
const WEBSITE = "22222222-2222-4222-8222-222222222222";
|
|
19
|
+
|
|
20
|
+
const CALL = {
|
|
21
|
+
callId: "call-1",
|
|
22
|
+
roomId: "room-1",
|
|
23
|
+
mediaUrl: "wss://media.test",
|
|
24
|
+
token: "ticket-1",
|
|
25
|
+
expiresAt: "2026-10-05T12:00:00Z",
|
|
26
|
+
identity: "visitor-33333333-3333-4333-8333-333333333333",
|
|
27
|
+
callKey: "key-1",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Route = (body: Record<string, unknown>) => { status: number; body: unknown };
|
|
31
|
+
|
|
32
|
+
let routes: Record<string, Route>;
|
|
33
|
+
let calls: { url: string; body: Record<string, unknown>; keepalive?: boolean }[];
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
calls = [];
|
|
37
|
+
routes = {
|
|
38
|
+
"/public/agent/calls/challenge": () => ({ status: 201, body: { challenge: "c1", difficulty: 4, expiresAt: "x" } }),
|
|
39
|
+
"/public/agent/calls": () => ({ status: 201, body: CALL }),
|
|
40
|
+
"/public/agent/calls/call-1/join": () => ({ status: 201, body: { ...CALL, token: "ticket-2" } }),
|
|
41
|
+
"/public/agent/calls/call-1/end": () => ({ status: 201, body: { ended: true } }),
|
|
42
|
+
};
|
|
43
|
+
vi.stubGlobal(
|
|
44
|
+
"fetch",
|
|
45
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
46
|
+
const path = url.replace(API, "");
|
|
47
|
+
const body = JSON.parse(String(init.body ?? "{}"));
|
|
48
|
+
calls.push({ url: path, body, keepalive: init.keepalive });
|
|
49
|
+
const route = routes[path];
|
|
50
|
+
const res = route ? route(body) : { status: 404, body: { message: "no route" } };
|
|
51
|
+
return new Response(JSON.stringify(res.body), { status: res.status, headers: { "Content-Type": "application/json" } });
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const wrapper =
|
|
61
|
+
(websiteId?: string) =>
|
|
62
|
+
({ children }: { children: ReactNode }) => (
|
|
63
|
+
<ForgeClientProvider apiUrl={API} profileId={PROFILE} websiteId={websiteId}>
|
|
64
|
+
{children}
|
|
65
|
+
</ForgeClientProvider>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const mount = ({ websiteId }: { websiteId?: string } = { websiteId: WEBSITE }) =>
|
|
69
|
+
renderHook(() => useAgentCall({ visitorId: "visitor-a", solver: { yieldToPage: async () => undefined } }), {
|
|
70
|
+
wrapper: wrapper(websiteId),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("useAgentCall: starting a call", () => {
|
|
74
|
+
it("asks for the check, solves it, and starts the call for THIS website", async () => {
|
|
75
|
+
const { result } = mount();
|
|
76
|
+
await act(() => result.current.start());
|
|
77
|
+
|
|
78
|
+
expect(result.current.state).toEqual({ phase: "live", call: CALL });
|
|
79
|
+
expect(calls.map((c) => c.url)).toEqual(["/public/agent/calls/challenge", "/public/agent/calls"]);
|
|
80
|
+
expect(calls[0].body).toEqual({ profileId: PROFILE, websiteId: WEBSITE, visitorId: "visitor-a" });
|
|
81
|
+
const start = calls[1].body;
|
|
82
|
+
expect(start).toMatchObject({ profileId: PROFILE, websiteId: WEBSITE, visitorId: "visitor-a", challenge: "c1" });
|
|
83
|
+
expect(String(start.solution)).toMatch(/^\d{1,12}$/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves websiteId out when the site does not know it, so the profile's agent answers", async () => {
|
|
87
|
+
const { result } = mount({});
|
|
88
|
+
await act(() => result.current.start());
|
|
89
|
+
expect(calls[0].body).toEqual({ profileId: PROFILE, visitorId: "visitor-a" });
|
|
90
|
+
expect(calls[1].body).not.toHaveProperty("websiteId");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([
|
|
94
|
+
[429, "too_many"],
|
|
95
|
+
[404, "unavailable"],
|
|
96
|
+
[409, "busy"],
|
|
97
|
+
[400, "check_failed"],
|
|
98
|
+
[503, "failed"],
|
|
99
|
+
])("reports a %i refusal as %s, with the API's message", async (status, kind) => {
|
|
100
|
+
routes["/public/agent/calls"] = () => ({ status, body: { status, message: `refused ${status}` } });
|
|
101
|
+
const { result } = mount();
|
|
102
|
+
await act(() => result.current.start());
|
|
103
|
+
expect(result.current.state).toEqual({ phase: "error", error: kind, message: `refused ${status}` });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reports a refused CHALLENGE too (404: this site's agent takes no calls) without starting anything", async () => {
|
|
107
|
+
routes["/public/agent/calls/challenge"] = () => ({ status: 404, body: { message: "no calls" } });
|
|
108
|
+
const { result } = mount();
|
|
109
|
+
await act(() => result.current.start());
|
|
110
|
+
expect(result.current.state).toMatchObject({ phase: "error", error: "unavailable" });
|
|
111
|
+
expect(calls.map((c) => c.url)).toEqual(["/public/agent/calls/challenge"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("can try again after an error", async () => {
|
|
115
|
+
routes["/public/agent/calls"] = () => ({ status: 409, body: { message: "busy" } });
|
|
116
|
+
const { result } = mount();
|
|
117
|
+
await act(() => result.current.start());
|
|
118
|
+
routes["/public/agent/calls"] = () => ({ status: 201, body: CALL });
|
|
119
|
+
await act(() => result.current.start());
|
|
120
|
+
expect(result.current.state.phase).toBe("live");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not start a second call while one is connecting", async () => {
|
|
124
|
+
const { result } = mount();
|
|
125
|
+
await act(async () => {
|
|
126
|
+
void result.current.start();
|
|
127
|
+
void result.current.start();
|
|
128
|
+
});
|
|
129
|
+
await waitFor(() => expect(result.current.state.phase).toBe("live"));
|
|
130
|
+
expect(calls.filter((c) => c.url === "/public/agent/calls/challenge")).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("useAgentCall: the media room's credentials", () => {
|
|
135
|
+
it("uses the start response first, then a fresh ticket per reconnect with the start's identity", async () => {
|
|
136
|
+
const { result } = mount();
|
|
137
|
+
await act(() => result.current.start());
|
|
138
|
+
|
|
139
|
+
await expect(result.current.getCredentials()).resolves.toEqual({ mediaUrl: CALL.mediaUrl, token: "ticket-1" });
|
|
140
|
+
expect(calls.some((c) => c.url.endsWith("/join"))).toBe(false);
|
|
141
|
+
|
|
142
|
+
await expect(result.current.getCredentials()).resolves.toEqual({ mediaUrl: CALL.mediaUrl, token: "ticket-2" });
|
|
143
|
+
const join = calls.find((c) => c.url === "/public/agent/calls/call-1/join");
|
|
144
|
+
expect(join?.body).toEqual({ callKey: CALL.callKey, identity: CALL.identity });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("useAgentCall: every way out ends the call on the server", () => {
|
|
149
|
+
it("hang up posts the end with the call key and shows the call ended", async () => {
|
|
150
|
+
const { result } = mount();
|
|
151
|
+
await act(() => result.current.start());
|
|
152
|
+
act(() => result.current.end());
|
|
153
|
+
|
|
154
|
+
expect(result.current.state).toEqual({ phase: "ended", callId: "call-1" });
|
|
155
|
+
await waitFor(() =>
|
|
156
|
+
expect(calls.find((c) => c.url === "/public/agent/calls/call-1/end")?.body).toEqual({ callKey: CALL.callKey }),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("hanging up while the call is being set up ends the call the server then creates", async () => {
|
|
161
|
+
let releaseStart!: () => void;
|
|
162
|
+
const gate = new Promise<void>((resolve) => (releaseStart = resolve));
|
|
163
|
+
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
|
164
|
+
const original = fetchMock.getMockImplementation()!;
|
|
165
|
+
fetchMock.mockImplementation(async (url: string, init: RequestInit) => {
|
|
166
|
+
if (url === `${API}/public/agent/calls`) await gate;
|
|
167
|
+
return original(url, init);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const { result } = mount();
|
|
171
|
+
let starting!: Promise<void>;
|
|
172
|
+
act(() => {
|
|
173
|
+
starting = result.current.start();
|
|
174
|
+
});
|
|
175
|
+
await waitFor(() => expect(calls.some((c) => c.url === "/public/agent/calls")).toBe(false));
|
|
176
|
+
act(() => result.current.end());
|
|
177
|
+
expect(result.current.state).toEqual({ phase: "idle" });
|
|
178
|
+
|
|
179
|
+
releaseStart();
|
|
180
|
+
await act(() => starting);
|
|
181
|
+
// Either the solver saw the abort before starting (nothing to end), or the
|
|
182
|
+
// start went out and the call it created is ended straight away. Never a
|
|
183
|
+
// live call nobody can hang up.
|
|
184
|
+
expect(result.current.state).toEqual({ phase: "idle" });
|
|
185
|
+
const started = calls.some((c) => c.url === "/public/agent/calls");
|
|
186
|
+
const ended = calls.some((c) => c.url === "/public/agent/calls/call-1/end");
|
|
187
|
+
expect(ended).toBe(started);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaving the page ends the call with a request that outlives the page", async () => {
|
|
191
|
+
const { result } = mount();
|
|
192
|
+
await act(() => result.current.start());
|
|
193
|
+
act(() => {
|
|
194
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
195
|
+
});
|
|
196
|
+
const end = calls.find((c) => c.url === "/public/agent/calls/call-1/end");
|
|
197
|
+
expect(end).toMatchObject({ body: { callKey: CALL.callKey }, keepalive: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("unmounting ends the call", async () => {
|
|
201
|
+
const { result, unmount } = mount();
|
|
202
|
+
await act(() => result.current.start());
|
|
203
|
+
unmount();
|
|
204
|
+
expect(calls.filter((c) => c.url === "/public/agent/calls/call-1/end")).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("does not end anything when there is no call", () => {
|
|
208
|
+
const { unmount } = mount();
|
|
209
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
210
|
+
unmount();
|
|
211
|
+
expect(calls).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
});
|