@tribe-nest/forge 3.67.0 → 3.74.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.67.0",
3
+ "version": "3.74.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { consentWithoutPhone, formConsentPayload } from "../useForms";
3
+ import type { FormField } from "../../../types/models";
4
+
5
+ const base = {
6
+ description: null,
7
+ placeholder: null,
8
+ isRequired: false,
9
+ options: null,
10
+ validation: null,
11
+ order: 0,
12
+ };
13
+
14
+ const phone: FormField = { ...base, id: "p1", type: "phone", label: "Phone" };
15
+ const consent: FormField = {
16
+ ...base,
17
+ id: "c1",
18
+ type: "call_consent",
19
+ label: "Call me back",
20
+ config: { channel: "ai_call", phoneFieldId: "p1" },
21
+ consent: {
22
+ channel: "ai_call",
23
+ phoneFieldId: "p1",
24
+ locale: "de",
25
+ version: "2026-10-07.1",
26
+ text: "Ich bin damit einverstanden, ...",
27
+ textHash: "a".repeat(64),
28
+ },
29
+ };
30
+ const email: FormField = { ...base, id: "e1", type: "email", label: "Email" };
31
+
32
+ describe("formConsentPayload", () => {
33
+ it("sends the hash of every consent box shown, in the language it was worded in", () => {
34
+ const payload = formConsentPayload([email, phone, consent], "en");
35
+ expect(payload?.locale).toBe("de");
36
+ expect(payload?.fields).toEqual([{ fieldId: "c1", textHash: "a".repeat(64) }]);
37
+ });
38
+
39
+ it("is undefined for a form without consent boxes", () => {
40
+ expect(formConsentPayload([email, phone], "en")).toBeUndefined();
41
+ });
42
+
43
+ it("leaves out a box the server sent no wording for (an old renderer cannot evidence it)", () => {
44
+ expect(formConsentPayload([{ ...consent, consent: null }], "en")).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("consentWithoutPhone", () => {
49
+ it("flags a ticked box whose phone field is empty", () => {
50
+ expect(consentWithoutPhone([phone, consent], { c1: "true" })).toBe("c1");
51
+ });
52
+
53
+ it("passes a ticked box with a number, and an unticked box without one", () => {
54
+ expect(consentWithoutPhone([phone, consent], { c1: "true", p1: "+4915112345678" })).toBeNull();
55
+ expect(consentWithoutPhone([phone, consent], { c1: "" })).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,155 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { parseChoiceEvent, streamAgentMessage, textWithoutChoice, type AgentStreamEvent } from "../useWebsiteAgent";
3
+
4
+ /**
5
+ * Playbook choices in the website chat stream: a `choice` event is validated
6
+ * before a widget draws it, unknown events are ignored by the consumer, and a
7
+ * tapped option goes back as `{ choice: { stepId, value } }` instead of text.
8
+ */
9
+
10
+ const sseResponse = (frames: string[]) => {
11
+ const body = new ReadableStream<Uint8Array>({
12
+ start(controller) {
13
+ const enc = new TextEncoder();
14
+ for (const f of frames) controller.enqueue(enc.encode(f));
15
+ controller.close();
16
+ },
17
+ });
18
+ return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
19
+ };
20
+
21
+ const frame = (data: unknown) => `data: ${typeof data === "string" ? data : JSON.stringify(data)}\n\n`;
22
+
23
+ const choice = {
24
+ type: "choice",
25
+ stepId: "step-1",
26
+ question: "Which service?",
27
+ multiple: false,
28
+ options: [
29
+ { value: "svc-a", label: "Haircut", durationText: "45 min", priceText: "$40" },
30
+ { value: "svc-b", label: "Colour" },
31
+ ],
32
+ text: "1. Haircut, 45 min, $40\n2. Colour",
33
+ };
34
+
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ describe("parseChoiceEvent", () => {
40
+ it("keeps a well formed choice with its card fields", () => {
41
+ expect(parseChoiceEvent(choice)).toEqual({
42
+ type: "choice",
43
+ stepId: "step-1",
44
+ question: "Which service?",
45
+ multiple: false,
46
+ options: [
47
+ { value: "svc-a", label: "Haircut", durationText: "45 min", priceText: "$40", description: undefined, imageUrl: undefined },
48
+ { value: "svc-b", label: "Colour", description: undefined, priceText: undefined, durationText: undefined, imageUrl: undefined },
49
+ ],
50
+ text: "1. Haircut, 45 min, $40\n2. Colour",
51
+ });
52
+ });
53
+
54
+ it("drops options without a value and falls back to the value as the label", () => {
55
+ const parsed = parseChoiceEvent({ ...choice, options: [{ label: "no value" }, { value: "x" }, null, 7] });
56
+ expect(parsed?.options).toEqual([
57
+ { value: "x", label: "x", description: undefined, priceText: undefined, durationText: undefined, imageUrl: undefined },
58
+ ]);
59
+ });
60
+
61
+ it("refuses a choice with no usable option, no step or the wrong type", () => {
62
+ expect(parseChoiceEvent({ ...choice, options: [] })).toBeNull();
63
+ expect(parseChoiceEvent({ ...choice, stepId: undefined })).toBeNull();
64
+ expect(parseChoiceEvent({ ...choice, type: "text_delta" })).toBeNull();
65
+ expect(parseChoiceEvent(null)).toBeNull();
66
+ });
67
+ });
68
+
69
+ describe("streamAgentMessage", () => {
70
+ const base = { apiUrl: "https://api.test", profileId: "p1", visitorId: "v1", conversationId: "c1" };
71
+
72
+ it("delivers a choice event between text deltas, and skips malformed and invalid frames", async () => {
73
+ vi.stubGlobal(
74
+ "fetch",
75
+ vi.fn().mockResolvedValue(
76
+ sseResponse([
77
+ frame({ type: "text_delta", delta: "Sure. " }),
78
+ frame("{not json"),
79
+ frame({ ...choice, options: [] }),
80
+ frame(choice),
81
+ frame({ type: "something_new", x: 1 }),
82
+ frame({ type: "turn_complete" }),
83
+ ]),
84
+ ),
85
+ );
86
+ const events: AgentStreamEvent[] = [];
87
+ await streamAgentMessage({ ...base, message: "book me in", onEvent: (ev) => events.push(ev) });
88
+
89
+ expect(events.map((e) => e.type)).toEqual(["text_delta", "choice", "something_new", "turn_complete"]);
90
+ const got = events[1];
91
+ expect(got.type === "choice" && got.options.map((o) => o.value)).toEqual(["svc-a", "svc-b"]);
92
+ });
93
+
94
+ it("sends a typed message as `message`", async () => {
95
+ const fetchMock = vi.fn().mockResolvedValue(sseResponse([]));
96
+ vi.stubGlobal("fetch", fetchMock);
97
+ await streamAgentMessage({ ...base, message: "hello", onEvent: () => {} });
98
+
99
+ const [url, init] = fetchMock.mock.calls[0];
100
+ expect(url).toBe("https://api.test/public/agent/conversations/c1/messages");
101
+ expect(JSON.parse(init.body)).toEqual({ profileId: "p1", visitorId: "v1", message: "hello" });
102
+ });
103
+
104
+ it("sends a tapped option as `choice`, with no message text", async () => {
105
+ const fetchMock = vi.fn().mockResolvedValue(sseResponse([]));
106
+ vi.stubGlobal("fetch", fetchMock);
107
+ await streamAgentMessage({ ...base, choice: { stepId: "step-1", value: "svc-a" }, onEvent: () => {} });
108
+
109
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
110
+ profileId: "p1",
111
+ visitorId: "v1",
112
+ choice: { stepId: "step-1", value: "svc-a" },
113
+ });
114
+ });
115
+
116
+ it("keeps reading when a consumer throws", async () => {
117
+ vi.stubGlobal(
118
+ "fetch",
119
+ vi.fn().mockResolvedValue(sseResponse([frame(choice), frame({ type: "turn_complete" })])),
120
+ );
121
+ const seen: string[] = [];
122
+ await streamAgentMessage({
123
+ ...base,
124
+ message: "hi",
125
+ onEvent: (ev) => {
126
+ seen.push(ev.type);
127
+ if (ev.type === "choice") throw new Error("render failed");
128
+ },
129
+ });
130
+ expect(seen).toEqual(["choice", "turn_complete"]);
131
+ });
132
+ });
133
+
134
+ describe("textWithoutChoice", () => {
135
+ const plain = "Which service?\n1. Haircut, 45 min, $40\n2. Colour";
136
+
137
+ it("removes the choice text streamed after the agent's words", () => {
138
+ expect(textWithoutChoice(`Sure, here are the options.\n\n${plain}`, { text: plain })).toBe("Sure, here are the options.");
139
+ });
140
+
141
+ it("leaves nothing when the choice text is all that was streamed", () => {
142
+ expect(textWithoutChoice(plain, { text: plain })).toBe("");
143
+ });
144
+
145
+ it("keeps the words when they do not end with the exact choice text", () => {
146
+ const words = "Here they are: 1. Haircut";
147
+ expect(textWithoutChoice(words, { text: plain })).toBe(words);
148
+ expect(textWithoutChoice(`${plain} and more`, { text: plain })).toBe(`${plain} and more`);
149
+ });
150
+
151
+ it("keeps the words when there is no choice", () => {
152
+ expect(textWithoutChoice("Hello", undefined)).toBe("Hello");
153
+ expect(textWithoutChoice("Hello", { text: "" })).toBe("Hello");
154
+ });
155
+ });
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { agentApiPath, createAgentConversation, fetchAgentTranscript, streamAgentMessage } from "../useWebsiteAgent";
3
+ import { countLinks } from "../../../ui/headless/agent/useAiAgent";
4
+
5
+ /**
6
+ * The website agent through an embed key: every call goes to the key's
7
+ * routes, where the key (not the profileId in the body) decides the business.
8
+ */
9
+ describe("the agent's routes through an embed key", () => {
10
+ const fetchSpy = vi.fn();
11
+ afterEach(() => {
12
+ fetchSpy.mockReset();
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ it("names the key's path, and the plain path without one", () => {
17
+ expect(agentApiPath(undefined)).toBe("/public/agent");
18
+ expect(agentApiPath(null)).toBe("/public/agent");
19
+ expect(agentApiPath("pk_embed_abc")).toBe("/public/embed/pk_embed_abc/agent");
20
+ });
21
+
22
+ it("creates the conversation, loads the transcript and streams through the key", async () => {
23
+ vi.stubGlobal("fetch", fetchSpy);
24
+ fetchSpy.mockResolvedValueOnce({ json: async () => ({ id: "c1" }) });
25
+ await createAgentConversation({ apiUrl: "https://api.test", profileId: "p1", visitorId: "v1", embedKey: "pk_embed_k" });
26
+ expect(fetchSpy.mock.calls[0][0]).toBe("https://api.test/public/embed/pk_embed_k/agent/conversations");
27
+
28
+ fetchSpy.mockResolvedValueOnce({ ok: false });
29
+ await fetchAgentTranscript({ apiUrl: "https://api.test", profileId: "p1", visitorId: "v1", conversationId: "c1", embedKey: "pk_embed_k" });
30
+ expect(fetchSpy.mock.calls[1][0]).toBe("https://api.test/public/embed/pk_embed_k/agent/conversations/c1?profileId=p1&visitorId=v1");
31
+
32
+ fetchSpy.mockResolvedValueOnce({ body: new Response("").body });
33
+ await streamAgentMessage({
34
+ apiUrl: "https://api.test",
35
+ profileId: "p1",
36
+ visitorId: "v1",
37
+ conversationId: "c1",
38
+ message: "hi",
39
+ onEvent: () => {},
40
+ embedKey: "pk_embed_k",
41
+ });
42
+ expect(fetchSpy.mock.calls[2][0]).toBe("https://api.test/public/embed/pk_embed_k/agent/conversations/c1/messages");
43
+ });
44
+ });
45
+
46
+ describe("countLinks", () => {
47
+ it("counts each distinct link once, Markdown or bare, and nothing else", () => {
48
+ expect(countLinks("No links here.")).toBe(0);
49
+ expect(countLinks("Pay here: [checkout](https://pay.example.com/l/abc) or https://pay.example.com/l/abc")).toBe(1);
50
+ expect(countLinks("Book https://a.example.com/x and read http://b.example.com.")).toBe(2);
51
+ });
52
+ });
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { fetchAgentTranscript, restoreTranscriptMessages } from "../useWebsiteAgent";
3
+
4
+ /**
5
+ * A returning visitor's chat: the saved conversation is loaded and drawn, and
6
+ * a saved playbook choice comes back as buttons that can be answered only
7
+ * while it is still the last message.
8
+ */
9
+
10
+ const choice = {
11
+ type: "choice",
12
+ stepId: "step-1",
13
+ question: "Which service?",
14
+ multiple: false,
15
+ options: [
16
+ { value: "svc-a", label: "Haircut" },
17
+ { value: "svc-b", label: "Colour" },
18
+ ],
19
+ text: "Which service?\n1. Haircut\n2. Colour",
20
+ };
21
+
22
+ afterEach(() => {
23
+ vi.unstubAllGlobals();
24
+ });
25
+
26
+ describe("restoreTranscriptMessages", () => {
27
+ it("keeps user and assistant text in order and drops anything else", () => {
28
+ const out = restoreTranscriptMessages({
29
+ messages: [
30
+ { role: "user", text: "Hi" },
31
+ { role: "tool", text: "internal" },
32
+ { role: "assistant", text: "Hello, how can I help?" },
33
+ { role: "assistant", text: "" },
34
+ null,
35
+ ],
36
+ });
37
+ expect(out).toEqual([
38
+ { role: "user", text: "Hi" },
39
+ { role: "assistant", text: "Hello, how can I help?" },
40
+ ]);
41
+ });
42
+
43
+ it("leaves the last message's choice answerable", () => {
44
+ const out = restoreTranscriptMessages({
45
+ messages: [
46
+ { role: "user", text: "Book me in" },
47
+ { role: "assistant", text: `Sure.\n\n${choice.text}`, choice },
48
+ ],
49
+ });
50
+ expect(out[1].choice?.stepId).toBe("step-1");
51
+ expect(out[1].choice?.options.map((o) => o.value)).toEqual(["svc-a", "svc-b"]);
52
+ expect(out[1].closed).toBeUndefined();
53
+ expect(out[1].picked).toBeUndefined();
54
+ });
55
+
56
+ it("marks an answered choice with the option whose label the visitor sent, and closes it", () => {
57
+ const out = restoreTranscriptMessages({
58
+ messages: [
59
+ { role: "assistant", text: choice.text, choice },
60
+ { role: "user", text: "Colour" },
61
+ { role: "assistant", text: "Colour it is." },
62
+ ],
63
+ });
64
+ expect(out[0]).toMatchObject({ picked: "svc-b", closed: true });
65
+ });
66
+
67
+ it("closes a choice the visitor answered by typing, with nothing picked", () => {
68
+ const out = restoreTranscriptMessages({
69
+ messages: [
70
+ { role: "assistant", text: choice.text, choice },
71
+ { role: "user", text: "Actually, what are your hours?" },
72
+ ],
73
+ });
74
+ expect(out[0].closed).toBe(true);
75
+ expect(out[0].picked).toBeUndefined();
76
+ });
77
+
78
+ it("ignores a malformed saved choice and a body with no messages", () => {
79
+ expect(restoreTranscriptMessages({ messages: [{ role: "assistant", text: "x", choice: { type: "choice" } }] })).toEqual([
80
+ { role: "assistant", text: "x" },
81
+ ]);
82
+ expect(restoreTranscriptMessages(null)).toEqual([]);
83
+ expect(restoreTranscriptMessages({ id: null })).toEqual([]);
84
+ });
85
+ });
86
+
87
+ describe("fetchAgentTranscript", () => {
88
+ it("asks for the conversation as this visitor of this profile", async () => {
89
+ const fetchMock = vi.fn().mockResolvedValue(
90
+ new Response(JSON.stringify({ id: "c-1", messages: [{ role: "user", text: "Hi" }] }), { status: 200 }),
91
+ );
92
+ vi.stubGlobal("fetch", fetchMock);
93
+ const saved = await fetchAgentTranscript({
94
+ apiUrl: "https://api.test",
95
+ token: "tok",
96
+ profileId: "p-1",
97
+ visitorId: "v-1",
98
+ conversationId: "c-1",
99
+ });
100
+ expect(saved).toEqual({ id: "c-1", messages: [{ role: "user", text: "Hi" }] });
101
+ const [url, init] = fetchMock.mock.calls[0];
102
+ expect(url).toBe("https://api.test/public/agent/conversations/c-1?profileId=p-1&visitorId=v-1");
103
+ expect(init.headers).toEqual({ authorization: "Bearer tok" });
104
+ });
105
+
106
+ it("is null when the conversation is not this visitor's any more", async () => {
107
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: null, messages: [] }), { status: 200 })));
108
+ expect(
109
+ await fetchAgentTranscript({ apiUrl: "https://api.test", profileId: "p", visitorId: "v", conversationId: "c" }),
110
+ ).toBeNull();
111
+ });
112
+
113
+ it("is null on an error response", async () => {
114
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("nope", { status: 400 })));
115
+ expect(
116
+ await fetchAgentTranscript({ apiUrl: "https://api.test", profileId: "p", visitorId: "v", conversationId: "c" }),
117
+ ).toBeNull();
118
+ });
119
+ });
@@ -0,0 +1,127 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import type { CreateOrderShippingRate } from "./useOrders";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+
5
+ /** What the pay page may show about a checkout link. Nothing about the buyer. */
6
+ export type CheckoutLinkView = {
7
+ status: "open" | "paid" | "expired" | "cancelled";
8
+ /** The buyer paid after the link closed: nothing was confirmed and the money goes back. */
9
+ needsRefund: boolean;
10
+ item: { type: "event_ticket" | "product" | "booking"; title: string } | null;
11
+ /**
12
+ * A booking link only: every session it holds (ISO), the business's own time
13
+ * zone, and the service's booking page for picking another time.
14
+ */
15
+ booking?: {
16
+ sessions: { startsAt: string; endsAt: string }[];
17
+ timezone: string | null;
18
+ servicePath: string | null;
19
+ };
20
+ /**
21
+ * Every line the link holds (phase 2: a link can hold several). Absent on a
22
+ * server older than phase 2, where `item` and `quantity` are the one line.
23
+ */
24
+ items?: CheckoutLinkLine[];
25
+ quantity: number;
26
+ /** Minor units, in `currency`. Null when the link holds several lines. */
27
+ unitPriceCents: number | null;
28
+ /** The items, in minor units. Shipping is `shipping.costCents`, on top. */
29
+ totalCents: number;
30
+ currency: string;
31
+ /** Something on the link ships: the address and its shipping (phase 2). */
32
+ shipping?: CheckoutLinkShipping | null;
33
+ /** ISO. When the hold ends if nobody pays. */
34
+ expiresAt: string;
35
+ };
36
+
37
+ export type CheckoutLinkLine = {
38
+ type: "event_ticket" | "product" | "booking";
39
+ title: string;
40
+ quantity: number;
41
+ unitPriceCents: number;
42
+ /** A product that ships. */
43
+ physical: boolean;
44
+ /** Products only: what the store's shipping rates are asked for. */
45
+ productId?: string;
46
+ productVariantId?: string;
47
+ };
48
+
49
+ export type CheckoutLinkAddress = {
50
+ address1: string;
51
+ city: string;
52
+ stateCode: string;
53
+ countryCode: string;
54
+ zip: string;
55
+ };
56
+
57
+ export type CheckoutLinkShipping = {
58
+ required: true;
59
+ /** The address is in and the shipping priced: Pay may go ahead. */
60
+ set: boolean;
61
+ address: CheckoutLinkAddress | null;
62
+ /** The shipping in the link's currency, minor units; null until set. */
63
+ costCents: number | null;
64
+ };
65
+
66
+ export type CheckoutLinkFinalizeResult = {
67
+ /** `fulfilled`, `paid` (settling), `unpaid`, or `needs_refund`. */
68
+ outcome: string;
69
+ link: CheckoutLinkView;
70
+ };
71
+
72
+ /**
73
+ * Open a checkout link by its token (`POST /public/checkout-links/open`).
74
+ *
75
+ * A POST, and not refetched on focus: opening extends the link's hold to the
76
+ * payment window, which is a write the page should make once per visit.
77
+ */
78
+ export function useCheckoutLink(token?: string | null) {
79
+ const { client, profileId } = useForge();
80
+ return useQuery<CheckoutLinkView>({
81
+ queryKey: ["checkout-link", token, profileId],
82
+ queryFn: async () => {
83
+ const res = await client.post("/public/checkout-links/open", { profileId, token });
84
+ return res.data;
85
+ },
86
+ enabled: !!profileId && !!client && !!token,
87
+ retry: false,
88
+ refetchOnWindowFocus: false,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Give the delivery address and the picked rate for what a link ships
94
+ * (`POST /public/checkout-links/shipping`). The server prices the shipping
95
+ * again from the store's own rates and answers the link as `open` does.
96
+ */
97
+ export function useSetCheckoutLinkShipping(token?: string | null) {
98
+ const { client, profileId } = useForge();
99
+ return useMutation<
100
+ CheckoutLinkView,
101
+ unknown,
102
+ { shippingAddress: CheckoutLinkAddress & { phone?: string }; selectedShippingRates: CreateOrderShippingRate[] }
103
+ >({
104
+ mutationFn: async (input) => {
105
+ const res = await client.post("/public/checkout-links/shipping", { profileId, token, ...input });
106
+ return res.data;
107
+ },
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Settle a checkout link on the return page (`POST /public/checkout-links/finalize`).
113
+ * Idempotent on the server: a reload settles nothing twice.
114
+ */
115
+ export function useCheckoutLinkFinalize(token?: string | null, enabled = true) {
116
+ const { client, profileId } = useForge();
117
+ return useQuery<CheckoutLinkFinalizeResult>({
118
+ queryKey: ["checkout-link-finalize", token, profileId],
119
+ queryFn: async () => {
120
+ const res = await client.post("/public/checkout-links/finalize", { profileId, token });
121
+ return res.data;
122
+ },
123
+ enabled: enabled && !!profileId && !!client && !!token,
124
+ retry: false,
125
+ refetchOnWindowFocus: false,
126
+ });
127
+ }
@@ -1,18 +1,23 @@
1
1
  import { useRef } from "react";
2
2
  import { fireServerMetaEvent, readMetaSignals } from "../../utils/metaEvents";
3
- import type { FormData, FormAnswerEntry } from "../../types/models";
3
+ import type { FormData, FormAnswerEntry, FormField, ConsentChannel } from "../../types/models";
4
4
  import { useForge } from "../../provider/ForgeProvider";
5
+ import { useForgeLocale } from "../../i18n";
5
6
  import { useMutation, useQuery } from "@tanstack/react-query";
6
7
 
7
- /** A single public form (fields + conditional edges) by id. */
8
+ /**
9
+ * A single public form (fields + conditional edges) by id. Consent boxes come
10
+ * back worded in the site's language.
11
+ */
8
12
  export function usePublicForm(formId?: string) {
9
13
  const { client, profileId } = useForge();
14
+ const locale = useForgeLocale();
10
15
 
11
16
  return useQuery<FormData>({
12
- queryKey: ["publicForm", formId],
17
+ queryKey: ["publicForm", formId, locale],
13
18
  queryFn: async () => {
14
19
  const res = await client.get(`/public/forms/${formId}`, {
15
- params: { profileId },
20
+ params: { profileId, locale },
16
21
  });
17
22
  return res.data;
18
23
  },
@@ -20,6 +25,68 @@ export function usePublicForm(formId?: string) {
20
25
  });
21
26
  }
22
27
 
28
+ /** What a submit sends about the consent boxes it showed. */
29
+ export type SubmitConsentPayload = {
30
+ locale: string;
31
+ pageUrl?: string;
32
+ fields: Array<{ fieldId: string; textHash: string }>;
33
+ };
34
+
35
+ /**
36
+ * The consent evidence for a submit: for every consent box the visitor was
37
+ * shown, the hash of the exact text beside it, plus the page. Undefined when
38
+ * the form has no consent boxes. Pass the fields that were actually shown.
39
+ */
40
+ export function formConsentPayload(fields: FormField[], locale: string): SubmitConsentPayload | undefined {
41
+ const shown = fields.filter((f) => f.type === "call_consent" && f.consent?.textHash);
42
+ if (shown.length === 0) return undefined;
43
+ return {
44
+ locale: shown[0].consent?.locale ?? locale,
45
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
46
+ fields: shown.map((f) => ({ fieldId: f.id, textHash: f.consent!.textHash })),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * A ticked consent box whose phone field holds no number cannot be evidenced:
52
+ * the consent is for a number. Returns the box's id, or null when all is well.
53
+ */
54
+ export function consentWithoutPhone(fields: FormField[], answers: Record<string, string>): string | null {
55
+ for (const f of fields) {
56
+ if (f.type !== "call_consent" || answers[f.id] !== "true") continue;
57
+ const phoneFieldId = f.consent?.phoneFieldId ?? f.config?.phoneFieldId ?? null;
58
+ if (!phoneFieldId || !answers[phoneFieldId]) return f.id;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** The locked consent text a business shows for a channel (newsletter signups). */
64
+ export type ConsentWording = {
65
+ channel: ConsentChannel;
66
+ locale: string;
67
+ version: string;
68
+ text: string;
69
+ textHash: string;
70
+ };
71
+
72
+ /**
73
+ * The exact consent text for a channel, worded with the business's name in the
74
+ * site's language. Show `text` as it is and send `textHash` back with the tick.
75
+ */
76
+ export function useConsentWording(channel: ConsentChannel | undefined) {
77
+ const { client, profileId } = useForge();
78
+ const locale = useForgeLocale();
79
+ return useQuery<ConsentWording>({
80
+ queryKey: ["consentWording", profileId, channel, locale],
81
+ queryFn: async () => {
82
+ const res = await client.get(`/public/consent/wording`, { params: { profileId, channel, locale } });
83
+ return res.data;
84
+ },
85
+ enabled: !!client && !!profileId && !!channel,
86
+ staleTime: 5 * 60 * 1000,
87
+ });
88
+ }
89
+
23
90
  export interface SubmitPublicFormInput {
24
91
  answers: FormAnswerEntry[];
25
92
  /**
@@ -33,6 +100,8 @@ export interface SubmitPublicFormInput {
33
100
  respondentEmail?: string;
34
101
  respondentName?: string;
35
102
  metadata?: Record<string, unknown>;
103
+ /** The consent boxes shown, from `formConsentPayload`. */
104
+ consent?: SubmitConsentPayload;
36
105
  }
37
106
 
38
107
  function newIdempotencyKey(): string {
@@ -22,7 +22,7 @@ export type LeadMagnetConfirmation = {
22
22
 
23
23
  /**
24
24
  * Confirms a lead-magnet subscription link and returns the lead magnet + profile.
25
- * The double opt-in confirm is a read (idempotent GET) — the download tracking
25
+ * The double opt-in confirm is a read (idempotent GET): the download tracking
26
26
  * POST stays a separate flow.
27
27
  */
28
28
  export function useLeadMagnetConfirm(leadMagnetId?: string, subscriberId?: string) {
@@ -63,9 +63,16 @@ export type JoinEmailListInput = {
63
63
  emailListId?: string;
64
64
  leadMagnetId?: string;
65
65
  firstName?: string;
66
- /** @deprecated send `firstName` — kept only for backward compatibility. */
66
+ /** @deprecated send `firstName`: kept only for backward compatibility. */
67
67
  name?: string;
68
68
  phoneNumber?: string;
69
+ /**
70
+ * The consent boxes the signup showed, ticked or not, each with the hash of
71
+ * the exact text beside it (from `useConsentWording`).
72
+ */
73
+ consents?: Array<{ channel: "ai_call" | "sms" | "whatsapp"; checked: boolean; textHash?: string; locale?: string }>;
74
+ /** The page the signup was on, for the consent evidence. */
75
+ pageUrl?: string;
69
76
  };
70
77
 
71
78
  /** Join an email list / subscribe (may require double opt-in confirmation). */