@tribe-nest/forge 3.65.0 → 3.69.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/useCheckoutLink.ts +70 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +42 -0
- package/src/i18n/en.json +42 -0
- package/src/index.ts +1 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/runtime/pages.ts +1 -0
- 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/headless/checkoutLink/useCheckoutLinkPayment.ts +78 -0
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CheckoutLinkPayment.tsx +303 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +231 -0
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
|
|
4
|
+
/** What the pay page may show about a checkout link. Nothing about the buyer. */
|
|
5
|
+
export type CheckoutLinkView = {
|
|
6
|
+
status: "open" | "paid" | "expired" | "cancelled";
|
|
7
|
+
/** The buyer paid after the link closed: nothing was confirmed and the money goes back. */
|
|
8
|
+
needsRefund: boolean;
|
|
9
|
+
item: { type: "event_ticket" | "product" | "booking"; title: string } | null;
|
|
10
|
+
/**
|
|
11
|
+
* A booking link only: every session it holds (ISO), the business's own time
|
|
12
|
+
* zone, and the service's booking page for picking another time.
|
|
13
|
+
*/
|
|
14
|
+
booking?: {
|
|
15
|
+
sessions: { startsAt: string; endsAt: string }[];
|
|
16
|
+
timezone: string | null;
|
|
17
|
+
servicePath: string | null;
|
|
18
|
+
};
|
|
19
|
+
quantity: number;
|
|
20
|
+
/** Minor units, in `currency`. */
|
|
21
|
+
unitPriceCents: number | null;
|
|
22
|
+
totalCents: number;
|
|
23
|
+
currency: string;
|
|
24
|
+
/** ISO. When the hold ends if nobody pays. */
|
|
25
|
+
expiresAt: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CheckoutLinkFinalizeResult = {
|
|
29
|
+
/** `fulfilled`, `paid` (settling), `unpaid`, or `needs_refund`. */
|
|
30
|
+
outcome: string;
|
|
31
|
+
link: CheckoutLinkView;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open a checkout link by its token (`POST /public/checkout-links/open`).
|
|
36
|
+
*
|
|
37
|
+
* A POST, and not refetched on focus: opening extends the link's hold to the
|
|
38
|
+
* payment window, which is a write the page should make once per visit.
|
|
39
|
+
*/
|
|
40
|
+
export function useCheckoutLink(token?: string | null) {
|
|
41
|
+
const { client, profileId } = useForge();
|
|
42
|
+
return useQuery<CheckoutLinkView>({
|
|
43
|
+
queryKey: ["checkout-link", token, profileId],
|
|
44
|
+
queryFn: async () => {
|
|
45
|
+
const res = await client.post("/public/checkout-links/open", { profileId, token });
|
|
46
|
+
return res.data;
|
|
47
|
+
},
|
|
48
|
+
enabled: !!profileId && !!client && !!token,
|
|
49
|
+
retry: false,
|
|
50
|
+
refetchOnWindowFocus: false,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Settle a checkout link on the return page (`POST /public/checkout-links/finalize`).
|
|
56
|
+
* Idempotent on the server: a reload settles nothing twice.
|
|
57
|
+
*/
|
|
58
|
+
export function useCheckoutLinkFinalize(token?: string | null, enabled = true) {
|
|
59
|
+
const { client, profileId } = useForge();
|
|
60
|
+
return useQuery<CheckoutLinkFinalizeResult>({
|
|
61
|
+
queryKey: ["checkout-link-finalize", token, profileId],
|
|
62
|
+
queryFn: async () => {
|
|
63
|
+
const res = await client.post("/public/checkout-links/finalize", { profileId, token });
|
|
64
|
+
return res.data;
|
|
65
|
+
},
|
|
66
|
+
enabled: enabled && !!profileId && !!client && !!token,
|
|
67
|
+
retry: false,
|
|
68
|
+
refetchOnWindowFocus: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -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…",
|
|
@@ -696,6 +713,31 @@
|
|
|
696
713
|
"forge.checkout_confirmation.total": "Gesamt",
|
|
697
714
|
"forge.checkout_confirmation.total_paid": "Bezahlt gesamt",
|
|
698
715
|
"forge.checkout_confirmation.view_orders": "Bestellungen ansehen",
|
|
716
|
+
"forge.checkout_link_payment.browse_store": "Zum Shop",
|
|
717
|
+
"forge.checkout_link_payment.cancelled_body": "Das Unternehmen hat diesen Link zurückgezogen. Es wurde nichts berechnet.",
|
|
718
|
+
"forge.checkout_link_payment.cancelled_title": "Dieser Link ist nicht mehr verfügbar",
|
|
719
|
+
"forge.checkout_link_payment.each": "je {price}",
|
|
720
|
+
"forge.checkout_link_payment.ended_body": "Wir konnten dies nicht länger für Sie reservieren, deshalb ist es wieder im Verkauf. Es wurde nichts berechnet.",
|
|
721
|
+
"forge.checkout_link_payment.ended_title": "Diese Reservierung ist abgelaufen",
|
|
722
|
+
"forge.checkout_link_payment.held_until": "Für Sie reserviert bis {time}",
|
|
723
|
+
"forge.checkout_link_payment.hold_noun": "Artikel",
|
|
724
|
+
"forge.checkout_link_payment.hold_noun_booking": "diesen Termin",
|
|
725
|
+
"forge.checkout_link_payment.not_found_body": "Dieser Link ist ungültig. Prüfen Sie die Adresse in Ihrer E-Mail.",
|
|
726
|
+
"forge.checkout_link_payment.not_found_title": "Link nicht gefunden",
|
|
727
|
+
"forge.checkout_link_payment.other_times": "Anderen Termin wählen",
|
|
728
|
+
"forge.checkout_link_payment.paid_body": "Vielen Dank. Die Details sind unterwegs an Ihre E-Mail-Adresse.",
|
|
729
|
+
"forge.checkout_link_payment.paid_title": "Zahlung erhalten",
|
|
730
|
+
"forge.checkout_link_payment.pay_amount": "{amount} bezahlen",
|
|
731
|
+
"forge.checkout_link_payment.payment_unavailable": "Die Zahlung ist gerade nicht möglich. Bitte versuchen Sie es gleich noch einmal.",
|
|
732
|
+
"forge.checkout_link_payment.preparing_payment": "Zahlung wird vorbereitet…",
|
|
733
|
+
"forge.checkout_link_payment.quantity": "Anzahl: {quantity}",
|
|
734
|
+
"forge.checkout_link_payment.title": "Für Sie reserviert",
|
|
735
|
+
"forge.checkout_link_payment.too_late_body": "Ihre Zahlung ist nach dem Ende dieser Reservierung eingegangen, deshalb wurde nichts gebucht. Ihre Zahlung wird erstattet.",
|
|
736
|
+
"forge.checkout_link_payment.too_late_title": "Ihre Zahlung wird erstattet",
|
|
737
|
+
"forge.checkout_link_payment.total": "Gesamt",
|
|
738
|
+
"forge.checkout_link_payment.try_again": "Erneut versuchen",
|
|
739
|
+
"forge.checkout_link_payment.unpaid_body": "Ihre Zahlung ist nicht durchgegangen, und es wurde nichts berechnet. Ihre Reservierung besteht weiter, Sie können es erneut versuchen.",
|
|
740
|
+
"forge.checkout_link_payment.unpaid_title": "Zahlung nicht abgeschlossen",
|
|
699
741
|
"forge.coaching_booking.aria_close": "Schließen",
|
|
700
742
|
"forge.coaching_booking.aria_next_month": "Nächster Monat",
|
|
701
743
|
"forge.coaching_booking.aria_next_week": "Nächste Woche",
|
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…",
|
|
@@ -696,6 +713,31 @@
|
|
|
696
713
|
"forge.checkout_confirmation.total": "Total",
|
|
697
714
|
"forge.checkout_confirmation.total_paid": "Total paid",
|
|
698
715
|
"forge.checkout_confirmation.view_orders": "View your orders",
|
|
716
|
+
"forge.checkout_link_payment.browse_store": "Browse the store",
|
|
717
|
+
"forge.checkout_link_payment.cancelled_body": "The business withdrew this link. Nothing was charged.",
|
|
718
|
+
"forge.checkout_link_payment.cancelled_title": "This link is no longer available",
|
|
719
|
+
"forge.checkout_link_payment.each": "{price} each",
|
|
720
|
+
"forge.checkout_link_payment.ended_body": "We could not hold this for you any longer, so it went back on sale. Nothing was charged.",
|
|
721
|
+
"forge.checkout_link_payment.ended_title": "This reservation has ended",
|
|
722
|
+
"forge.checkout_link_payment.held_until": "Held for you until {time}",
|
|
723
|
+
"forge.checkout_link_payment.hold_noun": "items",
|
|
724
|
+
"forge.checkout_link_payment.hold_noun_booking": "this time",
|
|
725
|
+
"forge.checkout_link_payment.not_found_body": "This link is not valid. Check the address in your email.",
|
|
726
|
+
"forge.checkout_link_payment.not_found_title": "Link not found",
|
|
727
|
+
"forge.checkout_link_payment.other_times": "Pick another time",
|
|
728
|
+
"forge.checkout_link_payment.paid_body": "Thank you. The details are on their way to your email.",
|
|
729
|
+
"forge.checkout_link_payment.paid_title": "Payment received",
|
|
730
|
+
"forge.checkout_link_payment.pay_amount": "Pay {amount}",
|
|
731
|
+
"forge.checkout_link_payment.payment_unavailable": "Payment is not available right now. Please try again in a moment.",
|
|
732
|
+
"forge.checkout_link_payment.preparing_payment": "Preparing payment…",
|
|
733
|
+
"forge.checkout_link_payment.quantity": "Quantity: {quantity}",
|
|
734
|
+
"forge.checkout_link_payment.title": "Held for you",
|
|
735
|
+
"forge.checkout_link_payment.too_late_body": "Your payment arrived after this reservation had ended, so nothing was booked. Your payment will be refunded.",
|
|
736
|
+
"forge.checkout_link_payment.too_late_title": "Your payment will be refunded",
|
|
737
|
+
"forge.checkout_link_payment.total": "Total",
|
|
738
|
+
"forge.checkout_link_payment.try_again": "Try again",
|
|
739
|
+
"forge.checkout_link_payment.unpaid_body": "Your payment did not go through, and nothing was charged. Your reservation is still held, so you can try again.",
|
|
740
|
+
"forge.checkout_link_payment.unpaid_title": "Payment not completed",
|
|
699
741
|
"forge.coaching_booking.aria_close": "Close",
|
|
700
742
|
"forge.coaching_booking.aria_next_month": "Next month",
|
|
701
743
|
"forge.coaching_booking.aria_next_week": "Next week",
|
package/src/index.ts
CHANGED
|
@@ -104,6 +104,7 @@ export * from "./data/queries/useMusicLink";
|
|
|
104
104
|
export * from "./data/queries/useEventSeries";
|
|
105
105
|
export * from "./data/queries/useInvoice";
|
|
106
106
|
export * from "./data/queries/usePaymentLink";
|
|
107
|
+
export * from "./data/queries/useCheckoutLink";
|
|
107
108
|
export * from "./data/queries/useLeadMagnet";
|
|
108
109
|
export * from "./data/queries/useBlogComments";
|
|
109
110
|
export * from "./data/queries/usePageActions";
|
|
@@ -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);
|
package/src/runtime/pages.ts
CHANGED
|
@@ -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
|
+
});
|