@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/formConsent.spec.ts +57 -0
- package/src/data/queries/_tests/websiteAgentChoice.spec.ts +155 -0
- package/src/data/queries/_tests/websiteAgentEmbed.spec.ts +52 -0
- package/src/data/queries/_tests/websiteAgentTranscript.spec.ts +119 -0
- package/src/data/queries/useCheckoutLink.ts +59 -2
- package/src/data/queries/useForms.ts +73 -4
- package/src/data/queries/useLeadMagnet.ts +9 -2
- package/src/data/queries/useWebsiteAgent.ts +205 -10
- package/src/i18n/de.json +10 -1
- package/src/i18n/en.json +10 -1
- package/src/provider/ForgeProvider.tsx +12 -2
- package/src/types/models.ts +24 -0
- package/src/ui/headless/agent/useAiAgent.ts +171 -20
- package/src/ui/headless/agent/visitorId.ts +27 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +39 -1
- package/src/ui/headless/forms/useEmailListForm.ts +45 -3
- package/src/ui/headless/forms/useSectionedForm.ts +26 -6
- package/src/ui/index.ts +2 -2
- package/src/ui/styled/AiAgentWidget.tsx +202 -34
- package/src/ui/styled/Checkout.tsx +1 -113
- package/src/ui/styled/CheckoutLinkPayment.tsx +312 -9
- package/src/ui/styled/EmailListForm.tsx +28 -2
- package/src/ui/styled/FormRenderer.tsx +55 -3
- package/src/ui/styled/ShippingRateSelector.tsx +122 -0
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +85 -0
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
embedKey,
|
|
169
|
+
...input,
|
|
63
170
|
onEvent: (ev) => {
|
|
64
171
|
if (ev.type === "text_delta") {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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")
|
|
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
|
|
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
|
|
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))
|
|
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(
|
|
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.
|