@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.
@@ -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
+ }
@@ -0,0 +1,116 @@
1
+ import { useCallback, useState } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
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";
11
+ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
12
+
13
+ export interface UseCheckoutLinkPaymentOptions {
14
+ /** Where the provider returns the buyer. Default: this page with `?finalise=1`. */
15
+ returnPath?: string;
16
+ /**
17
+ * True on the return leg (the provider sent the buyer back): the hook then
18
+ * settles the link instead of offering to pay.
19
+ */
20
+ returning?: boolean;
21
+ }
22
+
23
+ type ApiError = { response?: { status?: number; data?: { message?: string; code?: string; details?: { status?: string } } } };
24
+
25
+ /**
26
+ * Headless checkout link payment: the hosted pay page `/i/checkout/pay/<token>`.
27
+ *
28
+ * Loads the link (`open`, which extends its hold to the payment window), starts
29
+ * its ONE payment only when the buyer presses Pay (`start-payment` runs the
30
+ * server's checks first: a closed link or a hold whose items were taken is
31
+ * refused before any payment exists), and on the return leg settles it
32
+ * (`finalize`). Stripe renders from `clientSecret`; Paystack opens its modal.
33
+ */
34
+ export function useCheckoutLinkPayment(token?: string | null, opts: UseCheckoutLinkPaymentOptions = {}) {
35
+ const queryClient = useQueryClient();
36
+ const link = useCheckoutLink(opts.returning ? null : token);
37
+ const finalize = useCheckoutLinkFinalize(token, !!opts.returning);
38
+
39
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
40
+ const pathname = typeof window !== "undefined" ? window.location.pathname : "";
41
+ const returnUrl = token ? `${origin}${opts.returnPath ?? pathname}?finalise=1` : "";
42
+
43
+ const flow = usePaymentFlow({
44
+ path: "/public/checkout-links/start-payment",
45
+ body: { token },
46
+ returnUrl,
47
+ autoStart: false,
48
+ enabled: !!token,
49
+ });
50
+
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
+ );
76
+
77
+ const pay = useCallback(async () => {
78
+ setRefusal(null);
79
+ try {
80
+ await flow.start();
81
+ } catch (error) {
82
+ const data = (error as ApiError)?.response?.data;
83
+ setRefusal({
84
+ message: data?.message ?? "",
85
+ code: data?.code ?? null,
86
+ status: data?.details?.status ?? null,
87
+ });
88
+ // The link's own state may have moved (paid in another tab, expired).
89
+ await queryClient.invalidateQueries({ queryKey: ["checkout-link", token] });
90
+ }
91
+ }, [flow, queryClient, token]);
92
+
93
+ return {
94
+ link,
95
+ finalize,
96
+ returnUrl,
97
+ pay,
98
+ isStarting: flow.isStarting,
99
+ clientSecret: flow.clientSecret,
100
+ isPaystack: flow.isPaystack,
101
+ canOpenPaystack: flow.canOpenPaystack,
102
+ openPaystackCheckout: flow.openPaystackCheckout,
103
+ /** The server's refusal of the last Pay, in its own words, with its code. */
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,
112
+ /** When the hold ends: the link's own deadline, as Pay last extended it. */
113
+ holdExpiresAt:
114
+ (flow.result as { link?: { expiresAt?: string } } | null)?.link?.expiresAt ?? link.data?.expiresAt ?? null,
115
+ };
116
+ }
@@ -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 {
@@ -159,6 +159,7 @@ export {
159
159
  } from "./membership/membershipQuestionnaire";
160
160
  export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
161
161
  export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
162
+ export { useCheckoutLinkPayment, type UseCheckoutLinkPaymentOptions } from "./checkoutLink/useCheckoutLinkPayment";
162
163
  export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
163
164
  export {
164
165
  broadcastListStatus,
package/src/ui/index.ts CHANGED
@@ -94,6 +94,8 @@ export {
94
94
  export { Cart, type CartProps } from "./styled/Cart";
95
95
  /** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
96
96
  export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
97
+ /** The page a checkout link lands on: an item held for this person. Drop in at `/i/checkout/pay/$token`. */
98
+ export { CheckoutLinkPayment, type CheckoutLinkPaymentProps } from "./styled/CheckoutLinkPayment";
97
99
  // The resolver is exported alongside the component because the Craft themes
98
100
  // are a separate rendering stack (Tailwind, not inline styles) and cannot use
99
101
  // the component: but the legacy `color`/`size` fallback must not diverge.
@@ -203,9 +205,9 @@ export { ChatComposer, type ChatComposerProps } from "./styled/chat/ChatComposer
203
205
  export { ChatThreadPanel, type ChatThreadPanelProps } from "./styled/chat/ChatThreadPanel";
204
206
  export { MessageBubble, type MessageBubbleProps } from "./styled/chat/MessageBubble";
205
207
  export { NewDmDialog, type NewDmDialogProps } from "./styled/chat/NewDmDialog";
206
- export { AiAgentWidget, type AiAgentWidgetProps } from "./styled/AiAgentWidget";
208
+ export { AiAgentWidget, type AiAgentWidgetProps, type AiAgentWidgetEvent } from "./styled/AiAgentWidget";
207
209
  export { Markdown } from "./styled/Markdown";
208
- export { useAiAgent, type AiAgentMessage } from "./headless/agent/useAiAgent";
210
+ export { useAiAgent, countLinks, type AiAgentMessage, type AiAgentEvent, type UseAiAgentOptions } from "./headless/agent/useAiAgent";
209
211
  // "Call us". The room hook (`useAgentCallRoom`) is deliberately not exported
210
212
  // here: it imports the media SDK, and this entry is on every page. A custom
211
213
  // call UI imports the media hooks from `@tribe-nest/forge/media` itself.