@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.65.0",
3
+ "version": "3.69.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 { useForge } from "../../provider/ForgeProvider";
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") — the currency API amounts
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 — checkout totals come from the authoritative taxQuote. */
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 — the manifest `name` fallback when the
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 — this is the lever a white-label plan
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 — NOT baked into the build — so a
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 — no client-fetch flash, no hydration mismatch.
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 — settings change rarely; refetch keeps it fresh
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` — the
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 — either the published site (by
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 profile. When `enabled` is false the widget
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`, { params: { profileId } });
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({ profileId: params.profileId, visitorId: params.visitorId }),
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 — supplies the `useForge()` context (Axios
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);
@@ -59,6 +59,7 @@ export const INTERNAL_PAGES = [
59
59
  "blog/$slug",
60
60
  "checkout",
61
61
  "checkout/finalise",
62
+ "checkout/pay/$token",
62
63
  "checkout/resume",
63
64
  "coaching",
64
65
  "coaching/$slug",
@@ -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
+ });