@tribe-nest/forge 3.69.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.
@@ -1,85 +1,233 @@
1
- import { useCallback, useMemo, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useForge } from "../../../provider/ForgeProvider";
3
3
  import {
4
4
  createAgentConversation,
5
+ fetchAgentTranscript,
5
6
  streamAgentMessage,
6
7
  useWebsiteAgentConfig,
8
+ type AgentChoiceEvent,
9
+ type AgentChoiceReply,
7
10
  } from "../../../data/queries/useWebsiteAgent";
8
11
  import { isPathExcluded } from "./pathExclusion";
9
12
  import { useCurrentPath } from "./useCurrentPath";
10
- import { getAgentVisitorId } from "./visitorId";
13
+ import { getAgentVisitorId, getStoredConversationId, setStoredConversationId } from "./visitorId";
14
+
15
+ /**
16
+ * What happened in the chat, for a host page's analytics. Never carries what
17
+ * anyone typed or was told: only that something happened, and how much.
18
+ */
19
+ export type AiAgentEvent =
20
+ | { type: "chat_started" }
21
+ | { type: "message_sent"; choice: boolean }
22
+ | { type: "reply_received" }
23
+ | { type: "link_sent"; count: number }
24
+ | { type: "error" };
25
+
26
+ export interface UseAiAgentOptions {
27
+ /** Called for every `AiAgentEvent`. */
28
+ onEvent?: (event: AiAgentEvent) => void;
29
+ }
30
+
31
+ /** Links in a reply: Markdown links and bare http(s) URLs, each counted once. */
32
+ export function countLinks(text: string): number {
33
+ return new Set(text.match(/https?:\/\/[^\s)\]>"']+/g) ?? []).size;
34
+ }
11
35
 
12
36
  export interface AiAgentMessage {
13
37
  role: "user" | "assistant";
14
38
  text: string;
39
+ /** A playbook choice the assistant offered with this message. */
40
+ choice?: AgentChoiceEvent;
41
+ /** The value picked from `choice`, once one is. A picked choice cannot be picked again. */
42
+ picked?: string;
43
+ /** A restored choice the conversation already moved past; it shows, but cannot be answered. */
44
+ closed?: boolean;
15
45
  }
16
46
 
17
47
  /**
18
48
  * Headless controller for the website AI agent widget. Owns visitor identity,
19
49
  * the conversation (created lazily on first send), the message list, and the
20
50
  * streaming turn. A site can build any UI on top of this.
51
+ *
52
+ * A playbook step can offer a choice: it arrives on the assistant's message as
53
+ * `choice`, and `choose(stepId, value)` answers it. The pick is sent as a
54
+ * structured reply, not as text the model has to re-read, and shows in the
55
+ * conversation as the visitor's message.
56
+ *
57
+ * Inside an embed (`embedKey` on the provider) every call goes through the
58
+ * key's routes and no member token is ever sent: embeds have no sign-in.
21
59
  */
22
- export function useAiAgent() {
23
- const { apiUrl, profileId, websiteId, token } = useForge();
60
+ export function useAiAgent(options: UseAiAgentOptions = {}) {
61
+ const { apiUrl, profileId, websiteId, token: memberToken, embedKey } = useForge();
62
+ const token = embedKey ? null : memberToken;
24
63
  const config = useWebsiteAgentConfig();
25
64
  const path = useCurrentPath();
26
65
 
66
+ const onEventRef = useRef(options.onEvent);
67
+ onEventRef.current = options.onEvent;
68
+ const emit = useCallback((event: AiAgentEvent) => {
69
+ try {
70
+ onEventRef.current?.(event);
71
+ } catch {
72
+ /* a listener's error must not break the chat */
73
+ }
74
+ }, []);
75
+
27
76
  const [messages, setMessages] = useState<AiAgentMessage[]>([]);
28
77
  const [sending, setSending] = useState(false);
29
78
  const [error, setError] = useState<string | null>(null);
79
+ const [restoring, setRestoring] = useState(false);
30
80
  const conversationIdRef = useRef<string | null>(null);
81
+ /** The saved conversation being loaded; a send waits for it so it lands in the same conversation. */
82
+ const restoreRef = useRef<Promise<void> | null>(null);
31
83
  const visitorId = useMemo(getAgentVisitorId, []);
32
84
 
85
+ // A returning visitor sees the conversation so far. The id is remembered per
86
+ // profile; one that is no longer theirs (or no longer exists) is forgotten.
87
+ useEffect(() => {
88
+ if (!apiUrl || !profileId || visitorId === "ssr") return;
89
+ const storedId = getStoredConversationId(profileId);
90
+ if (!storedId) return;
91
+ const controller = new AbortController();
92
+ setRestoring(true);
93
+ const run = fetchAgentTranscript({
94
+ apiUrl,
95
+ token,
96
+ profileId,
97
+ visitorId,
98
+ conversationId: storedId,
99
+ signal: controller.signal,
100
+ embedKey,
101
+ })
102
+ .then((saved) => {
103
+ if (controller.signal.aborted) return;
104
+ if (!saved) {
105
+ setStoredConversationId(profileId, null);
106
+ return;
107
+ }
108
+ conversationIdRef.current = saved.id;
109
+ // Never replace a turn the visitor started while this was loading.
110
+ setMessages((prev) => (prev.length ? prev : saved.messages));
111
+ })
112
+ .catch(() => {
113
+ /* offline or aborted: the chat starts empty and a send makes a new conversation */
114
+ })
115
+ .finally(() => {
116
+ if (!controller.signal.aborted) setRestoring(false);
117
+ });
118
+ restoreRef.current = run;
119
+ return () => {
120
+ controller.abort();
121
+ restoreRef.current = null;
122
+ };
123
+ }, [apiUrl, profileId, token, visitorId, embedKey]);
124
+
33
125
  const ensureConversation = useCallback(async (): Promise<string | null> => {
126
+ if (restoreRef.current) await restoreRef.current;
34
127
  if (conversationIdRef.current) return conversationIdRef.current;
35
128
  if (!apiUrl || !profileId) return null;
36
- const res = await createAgentConversation({ apiUrl, token, profileId, websiteId, visitorId });
129
+ const res = await createAgentConversation({ apiUrl, token, profileId, websiteId, visitorId, embedKey });
37
130
  conversationIdRef.current = res.id;
131
+ setStoredConversationId(profileId, res.id);
132
+ if (res.id) emit({ type: "chat_started" });
38
133
  return res.id;
39
- }, [apiUrl, profileId, websiteId, token, visitorId]);
134
+ }, [apiUrl, profileId, websiteId, token, visitorId, embedKey, emit]);
40
135
 
41
- const send = useCallback(
42
- async (text: string) => {
43
- const trimmed = text.trim();
44
- if (!trimmed || sending || !apiUrl || !profileId) return;
136
+ /** One turn: the visitor's bubble, an empty assistant bubble, then the stream into it. */
137
+ const runTurn = useCallback(
138
+ async (userText: string, input: { message: string } | { choice: AgentChoiceReply }) => {
139
+ if (!apiUrl || !profileId) return;
45
140
  setError(null);
46
141
  setSending(true);
47
- setMessages((prev) => [...prev, { role: "user", text: trimmed }, { role: "assistant", text: "" }]);
142
+ setMessages((prev) => [...prev, { role: "user", text: userText }, { role: "assistant", text: "" }]);
143
+
144
+ const updateLast = (fn: (m: AiAgentMessage) => AiAgentMessage) =>
145
+ setMessages((prev) => {
146
+ const next = [...prev];
147
+ const last = next[next.length - 1];
148
+ if (last && last.role === "assistant") next[next.length - 1] = fn(last);
149
+ return next;
150
+ });
48
151
 
49
152
  try {
50
153
  const conversationId = await ensureConversation();
51
154
  if (!conversationId) {
52
155
  setError("The assistant is unavailable.");
53
- setSending(false);
156
+ emit({ type: "error" });
54
157
  return;
55
158
  }
159
+ emit({ type: "message_sent", choice: "choice" in input });
160
+ let reply = "";
161
+ let failed = false;
56
162
  await streamAgentMessage({
57
163
  apiUrl,
58
164
  token,
59
165
  profileId,
60
166
  visitorId,
61
167
  conversationId,
62
- message: trimmed,
168
+ embedKey,
169
+ ...input,
63
170
  onEvent: (ev) => {
64
171
  if (ev.type === "text_delta") {
65
- setMessages((prev) => {
66
- const next = [...prev];
67
- const last = next[next.length - 1];
68
- if (last && last.role === "assistant") next[next.length - 1] = { ...last, text: last.text + ev.delta };
69
- return next;
70
- });
172
+ reply += ev.delta;
173
+ updateLast((m) => ({ ...m, text: m.text + ev.delta }));
174
+ } else if (ev.type === "choice") {
175
+ // One choice per assistant message; a later one in the same turn replaces it.
176
+ updateLast((m) => ({ ...m, choice: ev, picked: undefined }));
71
177
  } else if (ev.type === "error") {
178
+ failed = true;
72
179
  setError(ev.message);
73
180
  }
74
181
  },
75
182
  });
183
+ if (failed) {
184
+ emit({ type: "error" });
185
+ } else {
186
+ emit({ type: "reply_received" });
187
+ const links = countLinks(reply);
188
+ if (links > 0) emit({ type: "link_sent", count: links });
189
+ }
76
190
  } catch {
77
191
  setError("Sorry, something went wrong. Please try again.");
192
+ emit({ type: "error" });
78
193
  } finally {
79
194
  setSending(false);
80
195
  }
81
196
  },
82
- [apiUrl, profileId, token, visitorId, sending, ensureConversation],
197
+ [apiUrl, profileId, token, visitorId, embedKey, ensureConversation, emit],
198
+ );
199
+
200
+ const send = useCallback(
201
+ async (text: string) => {
202
+ const trimmed = text.trim();
203
+ if (!trimmed || sending) return;
204
+ await runTurn(trimmed, { message: trimmed });
205
+ },
206
+ [sending, runTurn],
207
+ );
208
+
209
+ /**
210
+ * Answers a choice. Only the newest offered choice can be answered, once;
211
+ * the picked option's label becomes the visitor's message.
212
+ */
213
+ const choose = useCallback(
214
+ async (stepId: string, value: string) => {
215
+ if (sending) return;
216
+ let index = -1;
217
+ for (let i = messages.length - 1; i >= 0; i--) {
218
+ if (messages[i].choice) {
219
+ index = i;
220
+ break;
221
+ }
222
+ }
223
+ const target = index >= 0 ? messages[index] : undefined;
224
+ if (!target?.choice || target.choice.stepId !== stepId || target.picked !== undefined || target.closed) return;
225
+ const option = target.choice.options.find((o) => o.value === value);
226
+ if (!option) return;
227
+ setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, picked: value } : m)));
228
+ await runTurn(option.label, { choice: { stepId, value } });
229
+ },
230
+ [sending, messages, runTurn],
83
231
  );
84
232
 
85
233
  // The artist can keep the assistant off named pages. Checked here rather than
@@ -96,8 +244,11 @@ export function useAiAgent() {
96
244
  canCall: !!config.data?.enabled && !!config.data?.voiceEnabled && !excluded,
97
245
  visitorId,
98
246
  messages,
247
+ /** True while a returning visitor's saved conversation loads. */
248
+ restoring,
99
249
  sending,
100
250
  error,
101
251
  send,
252
+ choose,
102
253
  };
103
254
  }
@@ -18,3 +18,30 @@ export function getAgentVisitorId(): string {
18
18
  return `v_${Date.now()}`;
19
19
  }
20
20
  }
21
+
22
+ const conversationKey = (profileId: string) => `forge_agent_conversation:${profileId}`;
23
+
24
+ /**
25
+ * The visitor's open conversation with this profile's agent, so a reload or
26
+ * the next page shows the chat so far. Kept per profile: one browser can
27
+ * visit several sites on the same origin (previews, the platform domain).
28
+ */
29
+ export function getStoredConversationId(profileId: string): string | null {
30
+ if (typeof window === "undefined") return null;
31
+ try {
32
+ return window.localStorage.getItem(conversationKey(profileId));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Remembers (or, with null, forgets) the visitor's conversation with this profile. */
39
+ export function setStoredConversationId(profileId: string, conversationId: string | null): void {
40
+ if (typeof window === "undefined") return;
41
+ try {
42
+ if (conversationId) window.localStorage.setItem(conversationKey(profileId), conversationId);
43
+ else window.localStorage.removeItem(conversationKey(profileId));
44
+ } catch {
45
+ /* storage blocked: the chat simply starts afresh next time */
46
+ }
47
+ }
@@ -1,6 +1,13 @@
1
1
  import { useCallback, useState } from "react";
2
2
  import { useQueryClient } from "@tanstack/react-query";
3
- import { useCheckoutLink, useCheckoutLinkFinalize } from "../../../data/queries/useCheckoutLink";
3
+ import {
4
+ useCheckoutLink,
5
+ useCheckoutLinkFinalize,
6
+ useSetCheckoutLinkShipping,
7
+ type CheckoutLinkAddress,
8
+ } from "../../../data/queries/useCheckoutLink";
9
+ import type { CreateOrderShippingRate } from "../../../data/queries/useOrders";
10
+ import { useForge } from "../../../provider/ForgeProvider";
4
11
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
5
12
 
6
13
  export interface UseCheckoutLinkPaymentOptions {
@@ -42,6 +49,30 @@ export function useCheckoutLinkPayment(token?: string | null, opts: UseCheckoutL
42
49
  });
43
50
 
44
51
  const [refusal, setRefusal] = useState<{ message: string; code: string | null; status: string | null } | null>(null);
52
+ const [shippingError, setShippingError] = useState<string | null>(null);
53
+ const { profileId } = useForge();
54
+ const shippingMutation = useSetCheckoutLinkShipping(token);
55
+
56
+ /**
57
+ * The delivery address and the rate picked for it (phase 2). The answer is
58
+ * the link as the page reads it, with the shipping priced by the server, so
59
+ * the page shows that rather than the client's own sum.
60
+ */
61
+ const saveShipping = useCallback(
62
+ async (input: { shippingAddress: CheckoutLinkAddress & { phone?: string }; selectedShippingRates: CreateOrderShippingRate[] }) => {
63
+ setShippingError(null);
64
+ try {
65
+ const view = await shippingMutation.mutateAsync(input);
66
+ queryClient.setQueryData(["checkout-link", token, profileId], view);
67
+ return true;
68
+ } catch (error) {
69
+ setShippingError((error as ApiError)?.response?.data?.message ?? "");
70
+ await queryClient.invalidateQueries({ queryKey: ["checkout-link", token] });
71
+ return false;
72
+ }
73
+ },
74
+ [shippingMutation, queryClient, token, profileId],
75
+ );
45
76
 
46
77
  const pay = useCallback(async () => {
47
78
  setRefusal(null);
@@ -71,6 +102,13 @@ export function useCheckoutLinkPayment(token?: string | null, opts: UseCheckoutL
71
102
  openPaystackCheckout: flow.openPaystackCheckout,
72
103
  /** The server's refusal of the last Pay, in its own words, with its code. */
73
104
  refusal,
105
+ /** Phase 2: give the delivery address and picked rate for what the link ships. */
106
+ saveShipping,
107
+ isSavingShipping: shippingMutation.isPending,
108
+ /** The server's refusal of the last address, in its own words ("" when it gave none). */
109
+ shippingError,
110
+ /** A payment exists on the page: the address can no longer be changed here. */
111
+ paymentStarted: !!flow.clientSecret || flow.canOpenPaystack,
74
112
  /** When the hold ends: the link's own deadline, as Pay last extended it. */
75
113
  holdExpiresAt:
76
114
  (flow.result as { link?: { expiresAt?: string } } | null)?.link?.expiresAt ?? link.data?.expiresAt ?? null,
@@ -1,6 +1,8 @@
1
1
  import { useState } from "react";
2
2
  import type { CountryCode } from "libphonenumber-js";
3
3
  import { useJoinEmailList } from "../../../data/queries/useLeadMagnet";
4
+ import { useConsentWording } from "../../../data/queries/useForms";
5
+ import type { ConsentChannel } from "../../../types/models";
4
6
  import { usePhoneInput } from "./usePhoneInput";
5
7
 
6
8
  export type FormStatus = "idle" | "submitting" | "success" | "error";
@@ -17,6 +19,13 @@ export interface UseEmailListFormOptions {
17
19
  * a number ends up stored under the wrong country.
18
20
  */
19
21
  defaultCountry?: CountryCode;
22
+ /**
23
+ * Offer consent boxes for these channels: `ai_call` (calls, including by an
24
+ * AI voice agent), `sms`, `whatsapp`. One box per channel, unticked, never
25
+ * required, with the business's locked wording. Turns the phone field on,
26
+ * because the consent is for a number.
27
+ */
28
+ consentChannels?: ConsentChannel[];
20
29
  }
21
30
 
22
31
  const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -38,8 +47,16 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
38
47
  * asked us not to. `usePhoneInput` asks for the country instead of guessing
39
48
  * it, which is the only point in the flow where it can be known.
40
49
  */
50
+ const channels = opts.consentChannels ?? [];
51
+ const wantsPhone = !!opts.includePhoneNumber || channels.length > 0;
52
+ // One query per channel, always called (hooks cannot be conditional).
53
+ const aiCall = useConsentWording(channels.includes("ai_call") ? "ai_call" : undefined);
54
+ const sms = useConsentWording(channels.includes("sms") ? "sms" : undefined);
55
+ const whatsapp = useConsentWording(channels.includes("whatsapp") ? "whatsapp" : undefined);
56
+ const wordings = { ai_call: aiCall.data, sms: sms.data, whatsapp: whatsapp.data };
57
+ const [consentTicks, setConsentTicks] = useState<Record<string, boolean>>({});
41
58
  const phone = usePhoneInput({
42
- enabled: !!opts.includePhoneNumber,
59
+ enabled: wantsPhone,
43
60
  defaultCountry: opts.defaultCountry,
44
61
  mode: "messaging",
45
62
  });
@@ -57,11 +74,21 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
57
74
  setError("Please enter your name");
58
75
  return;
59
76
  }
60
- if (opts.includePhoneNumber && !phone.e164) {
77
+ const anyTicked = channels.some((c) => consentTicks[c]);
78
+ if ((opts.includePhoneNumber || anyTicked) && !phone.e164) {
61
79
  phone.markTouched();
62
80
  setError("Please enter a valid phone number");
63
81
  return;
64
82
  }
83
+ // A box is only sent once its wording is on screen: the hash is the evidence.
84
+ const consents = channels
85
+ .filter((c) => !!wordings[c])
86
+ .map((c) => ({
87
+ channel: c,
88
+ checked: !!consentTicks[c],
89
+ textHash: wordings[c]!.textHash,
90
+ locale: wordings[c]!.locale,
91
+ }));
65
92
  setStatus("submitting");
66
93
  try {
67
94
  const data = await join.mutateAsync({
@@ -69,7 +96,10 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
69
96
  emailListId: opts.emailListId,
70
97
  leadMagnetId: opts.leadMagnetId,
71
98
  firstName: opts.includeName ? name.trim() : undefined,
72
- phoneNumber: opts.includePhoneNumber ? (phone.e164 ?? undefined) : undefined,
99
+ phoneNumber: wantsPhone ? (phone.e164 ?? undefined) : undefined,
100
+ ...(consents.length > 0
101
+ ? { consents, pageUrl: typeof window !== "undefined" ? window.location.href : undefined }
102
+ : {}),
73
103
  });
74
104
  setRequiresConfirmation(!!data?.requiresConfirmation);
75
105
  setStatus("success");
@@ -87,6 +117,18 @@ export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
87
117
  setName,
88
118
  /** The phone field's controller. Hand it to <PhoneInput controller={...} />. */
89
119
  phone,
120
+ /** Whether a phone field should show (asked for, or needed by a consent box). */
121
+ showPhone: wantsPhone,
122
+ /**
123
+ * The consent boxes to show, in order: the channel, the exact text (show it
124
+ * as it is) and whether it is ticked. A box whose wording has not loaded is
125
+ * left out rather than shown with different words.
126
+ */
127
+ consentBoxes: channels
128
+ .filter((c) => !!wordings[c])
129
+ .map((c) => ({ channel: c, text: wordings[c]!.text, checked: !!consentTicks[c] })),
130
+ setConsent: (channel: ConsentChannel, checked: boolean) =>
131
+ setConsentTicks((prev) => ({ ...prev, [channel]: checked })),
90
132
  /** E.164 or empty. Kept for callers that only want the value. */
91
133
  phoneNumber: phone.e164 ?? "",
92
134
  /**
@@ -1,5 +1,11 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { usePublicForm, useSubmitPublicForm } from "../../../data/queries/useForms";
2
+ import {
3
+ usePublicForm,
4
+ useSubmitPublicForm,
5
+ formConsentPayload,
6
+ consentWithoutPhone,
7
+ } from "../../../data/queries/useForms";
8
+ import { translateForge, useForgeLocale } from "../../../i18n";
3
9
  import type { FormData, FormField, FormFieldEdge } from "../../../types/models";
4
10
 
5
11
  const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -98,7 +104,7 @@ function buildModel(form?: FormData) {
98
104
  sectionEdges.set(e.sourceSectionId, a);
99
105
  } else if (e.sourceFieldId && e.targetFieldId && e.condition) {
100
106
  // A field→field edge with a CONDITION is an in-page visibility rule ("show
101
- // target when source matches") — treat scope "field"/"visibility" alike
107
+ // target when source matches"): treat scope "field"/"visibility" alike
102
108
  // since jumps don't apply to paged forms. A field→field edge WITHOUT a
103
109
  // condition is not a visibility rule and must NOT hide its target.
104
110
  const a = visByTarget.get(e.targetFieldId) ?? [];
@@ -116,11 +122,13 @@ function buildModel(form?: FormData) {
116
122
  * section→section branching (incl. submit), Back navigation, and submit. Bring
117
123
  * your own markup (or use the styled `<SectionedFormRenderer>`).
118
124
  *
119
- * For forms with no sections, prefer the flat `<FormRenderer>` — this hook has
125
+ * For forms with no sections, prefer the flat `<FormRenderer>`: this hook has
120
126
  * nothing to page through.
121
127
  */
122
128
  export function useSectionedForm(formId?: string) {
123
129
  const { data: rawForm, isLoading } = usePublicForm(formId);
130
+ const locale = useForgeLocale();
131
+ const t = (key: string) => translateForge(locale, key);
124
132
  const submitMutation = useSubmitPublicForm(formId);
125
133
 
126
134
  // Seeded per-mount option shuffle for quiz-mode forms (no-op otherwise).
@@ -135,7 +143,7 @@ export function useSectionedForm(formId?: string) {
135
143
  const [error, setError] = useState("");
136
144
  const [isCompleted, setIsCompleted] = useState(false);
137
145
  // Graded quiz result (null until submit + only for quiz-mode forms). Shaped by
138
- // the server's reveal policy — see docs/archive/shipped/quizzes-certification-initiative.md.
146
+ // the server's reveal policy: see docs/archive/shipped/quizzes-certification-initiative.md.
139
147
  const [quizResult, setQuizResult] = useState<Record<string, unknown> | null>(null);
140
148
 
141
149
  // Start at the first section once the form loads.
@@ -194,13 +202,25 @@ export function useSectionedForm(formId?: string) {
194
202
  // Send answers only for active fields in the sections actually visited.
195
203
  const visited = stack.length ? stack : sections[0] ? [sections[0].id] : [];
196
204
  const entries: { fieldId: string; value: string | null }[] = [];
205
+ const shown: FormField[] = [];
197
206
  for (const sid of visited) {
198
207
  for (const f of fieldsBySection.get(sid) ?? []) {
199
- if (isActive(f)) entries.push({ fieldId: f.id, value: answers[f.id] || null });
208
+ if (isActive(f)) {
209
+ entries.push({ fieldId: f.id, value: answers[f.id] || null });
210
+ shown.push(f);
211
+ }
200
212
  }
201
213
  }
214
+ if (consentWithoutPhone(shown, answers)) {
215
+ setError(t("forge.form_renderer.consent_phone_required"));
216
+ return;
217
+ }
202
218
  try {
203
- const res = (await submitMutation.mutateAsync(entries)) as { result?: Record<string, unknown> | null };
219
+ const res = (await submitMutation.mutateAsync({
220
+ answers: entries,
221
+ // The consent boxes the visitor was actually shown, with their text hashes.
222
+ consent: formConsentPayload(shown, locale),
223
+ })) as { result?: Record<string, unknown> | null };
204
224
  if (res?.result) setQuizResult(res.result);
205
225
  setIsCompleted(true);
206
226
  } catch {
package/src/ui/index.ts CHANGED
@@ -205,9 +205,9 @@ export { ChatComposer, type ChatComposerProps } from "./styled/chat/ChatComposer
205
205
  export { ChatThreadPanel, type ChatThreadPanelProps } from "./styled/chat/ChatThreadPanel";
206
206
  export { MessageBubble, type MessageBubbleProps } from "./styled/chat/MessageBubble";
207
207
  export { NewDmDialog, type NewDmDialogProps } from "./styled/chat/NewDmDialog";
208
- export { AiAgentWidget, type AiAgentWidgetProps } from "./styled/AiAgentWidget";
208
+ export { AiAgentWidget, type AiAgentWidgetProps, type AiAgentWidgetEvent } from "./styled/AiAgentWidget";
209
209
  export { Markdown } from "./styled/Markdown";
210
- export { useAiAgent, type AiAgentMessage } from "./headless/agent/useAiAgent";
210
+ export { useAiAgent, countLinks, type AiAgentMessage, type AiAgentEvent, type UseAiAgentOptions } from "./headless/agent/useAiAgent";
211
211
  // "Call us". The room hook (`useAgentCallRoom`) is deliberately not exported
212
212
  // here: it imports the media SDK, and this entry is on every page. A custom
213
213
  // call UI imports the media hooks from `@tribe-nest/forge/media` itself.