@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,11 +1,12 @@
1
- import { lazy, Suspense, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
1
+ import { Fragment, lazy, Suspense, useCallback, useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
2
  import { Phone, PhoneOff } from "lucide-react";
3
3
  import { useForgeT } from "../../i18n";
4
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
5
  import { readableTextOn } from "../theme/contrast";
6
- import { useAiAgent } from "../headless/agent/useAiAgent";
6
+ import { useAiAgent, type AiAgentEvent } from "../headless/agent/useAiAgent";
7
7
  import { useAgentCall, type AgentCallErrorKind } from "../headless/agent/useAgentCall";
8
8
  import type { AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
9
+ import { textWithoutChoice, type AgentChoiceEvent } from "../../data/queries/useWebsiteAgent";
9
10
  import { Markdown } from "./Markdown";
10
11
 
11
12
  // The live call pulls in the media SDK and mediasoup, which no visitor needs
@@ -33,8 +34,19 @@ export interface AiAgentWidgetProps {
33
34
  title?: string;
34
35
  /** Stacking order for the floating elements. Default 9999. */
35
36
  zIndex?: number;
37
+ /** Open the chat panel on mount (the embed loader hands over an open chat). */
38
+ defaultOpen?: boolean;
39
+ /**
40
+ * What happened in the chat, for analytics: opened, closed, started, a
41
+ * message sent, a reply, links in a reply, an error. Carries no text and no
42
+ * personal data. The embed loader turns these into DOM events.
43
+ */
44
+ onEvent?: (event: AiAgentWidgetEvent) => void;
36
45
  }
37
46
 
47
+ /** `AiAgentEvent`s plus the panel opening and closing. */
48
+ export type AiAgentWidgetEvent = AiAgentEvent | { type: "chat_opened" } | { type: "chat_closed" };
49
+
38
50
  /**
39
51
  * Drop-in website AI agent. A self-contained floating chat widget: fetches its
40
52
  * own config for the site's profile, renders a fixed-position bubble + panel,
@@ -48,18 +60,44 @@ export interface AiAgentWidgetProps {
48
60
  * us"): a voice call with the same agent, in the browser. `useAgentCall()` is
49
61
  * its headless half.
50
62
  */
51
- export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
63
+ export function AiAgentWidget({
64
+ position = "bottom-right",
65
+ accentColor,
66
+ title,
67
+ zIndex = 9999,
68
+ defaultOpen = false,
69
+ onEvent,
70
+ }: AiAgentWidgetProps) {
52
71
  const t = useForgeT();
53
72
  const theme = useForgeTheme();
54
- const { isEnabled, canCall, config, messages, sending, error, send, visitorId } = useAiAgent();
73
+ const onEventRef = useRef(onEvent);
74
+ onEventRef.current = onEvent;
75
+ const emit = useCallback((event: AiAgentWidgetEvent) => onEventRef.current?.(event), []);
76
+ const { isEnabled, canCall, config, messages, restoring, sending, error, send, choose, visitorId } = useAiAgent({
77
+ onEvent: emit,
78
+ });
55
79
  const call = useAgentCall({ visitorId });
56
- const [view, setView] = useState<"chat" | "call" | null>(null);
80
+ const [view, setView] = useState<"chat" | "call" | null>(defaultOpen ? "chat" : null);
57
81
  const open = view === "chat";
58
- const setOpen = (next: boolean | ((o: boolean) => boolean)) =>
59
- setView((current) => {
60
- const value = typeof next === "function" ? next(current === "chat") : next;
61
- return value ? "chat" : null;
62
- });
82
+ const panelId = useId();
83
+ const launcherRef = useRef<HTMLButtonElement>(null);
84
+ const inputRef = useRef<HTMLInputElement>(null);
85
+ const setOpen = (next: boolean | ((o: boolean) => boolean)) => {
86
+ const value = typeof next === "function" ? next(open) : next;
87
+ if (value === open) return;
88
+ setView(value ? "chat" : null);
89
+ emit({ type: value ? "chat_opened" : "chat_closed" });
90
+ // Closing hands focus back to the button that opened it.
91
+ if (!value) launcherRef.current?.focus();
92
+ };
93
+ // An open panel takes the focus, so a keyboard user can type at once.
94
+ useEffect(() => {
95
+ if (open) inputRef.current?.focus();
96
+ }, [open]);
97
+ // A chat handed over open (the embed) counts as opened.
98
+ useEffect(() => {
99
+ if (defaultOpen) emit({ type: "chat_opened" });
100
+ }, [defaultOpen, emit]);
63
101
  const [roomStatus, setRoomStatus] = useState<AgentCallRoomStatus>("connecting");
64
102
  const [input, setInput] = useState("");
65
103
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -95,9 +133,10 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
95
133
  if (!isEnabled) return null;
96
134
 
97
135
  const agentName = config?.displayName || heading;
136
+ const lastChoiceIndex = messages.reduce((acc, m, i) => (m.choice ? i : acc), -1);
98
137
 
99
138
  const submit = () => {
100
- if (!input.trim()) return;
139
+ if (!input.trim() || sending) return;
101
140
  send(input);
102
141
  setInput("");
103
142
  };
@@ -118,6 +157,15 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
118
157
  >
119
158
  {open && (
120
159
  <div
160
+ id={panelId}
161
+ role="dialog"
162
+ aria-label={heading}
163
+ onKeyDown={(e) => {
164
+ if (e.key === "Escape") {
165
+ e.stopPropagation();
166
+ setOpen(false);
167
+ }
168
+ }}
121
169
  style={{
122
170
  width: "min(380px, calc(100vw - 40px))",
123
171
  height: "min(560px, calc(100vh - 120px))",
@@ -140,42 +188,67 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
140
188
  </div>
141
189
 
142
190
  <div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
143
- {messages.length === 0 && (
191
+ {messages.length === 0 && !restoring && (
144
192
  <div style={{ opacity: 0.7, fontSize: 14 }}>
145
193
  {config?.greeting || t("forge.ai_agent_widget.default_greeting")}
146
194
  </div>
147
195
  )}
148
- {messages.map((m, i) => (
149
- <div
150
- key={i}
151
- style={{
152
- alignSelf: m.role === "user" ? "flex-end" : "flex-start",
153
- maxWidth: "85%",
154
- padding: "8px 12px",
155
- borderRadius: 14,
156
- fontSize: 14,
157
- whiteSpace: m.role === "user" ? "pre-wrap" : "normal",
158
- background: m.role === "user" ? accent : `${theme.colors.text}12`,
159
- color: m.role === "user" ? onAccent : theme.colors.text,
160
- }}
161
- >
162
- {m.role === "assistant" && m.text ? (
163
- <Markdown>{m.text}</Markdown>
164
- ) : (
165
- m.text || (sending && i === messages.length - 1 ? "…" : "")
166
- )}
167
- </div>
168
- ))}
196
+ {messages.map((m, i) => {
197
+ // The choice's plain text was streamed into the words too; the
198
+ // buttons show it, so it is not repeated. A choice with no words
199
+ // of its own shows its question.
200
+ const text = textWithoutChoice(m.text, m.choice) || (m.choice ? m.choice.question : "");
201
+ return (
202
+ <Fragment key={i}>
203
+ {(text || !m.choice) && (
204
+ <div
205
+ style={{
206
+ alignSelf: m.role === "user" ? "flex-end" : "flex-start",
207
+ maxWidth: "85%",
208
+ padding: "8px 12px",
209
+ borderRadius: 14,
210
+ fontSize: 14,
211
+ whiteSpace: m.role === "user" ? "pre-wrap" : "normal",
212
+ background: m.role === "user" ? accent : `${theme.colors.text}12`,
213
+ color: m.role === "user" ? onAccent : theme.colors.text,
214
+ }}
215
+ >
216
+ {m.role === "assistant" && text ? (
217
+ <Markdown>{text}</Markdown>
218
+ ) : (
219
+ text || (sending && i === messages.length - 1 ? "…" : "")
220
+ )}
221
+ </div>
222
+ )}
223
+ {m.choice && (
224
+ <ChoiceOptions
225
+ choice={m.choice}
226
+ picked={m.picked}
227
+ // Only the newest choice can be answered, and only once; a restored one the chat moved past is closed.
228
+ disabled={sending || m.closed || m.picked !== undefined || i !== lastChoiceIndex}
229
+ accent={accent}
230
+ onAccent={onAccent}
231
+ onPick={(value) => void choose(m.choice!.stepId, value)}
232
+ />
233
+ )}
234
+ </Fragment>
235
+ );
236
+ })}
169
237
  {error && <div style={{ color: "#c0392b", fontSize: 13 }}>{error}</div>}
170
238
  </div>
171
239
 
172
240
  <div style={{ display: "flex", gap: 8, padding: 12, borderTop: `1px solid ${theme.colors.text}1a` }}>
173
241
  <input
242
+ ref={inputRef}
243
+ aria-label={t("forge.ai_agent_widget.aria_message")}
174
244
  value={input}
175
245
  onChange={(e) => setInput(e.target.value)}
176
246
  onKeyDown={(e) => e.key === "Enter" && submit()}
177
247
  placeholder={t("forge.ai_agent_widget.input_placeholder")}
178
- disabled={sending}
248
+ // Not disabled while a reply streams: a disabled input drops the
249
+ // focus to the page, and with it Escape and the next message.
250
+ // `submit` waits for the reply instead.
251
+ aria-busy={sending}
179
252
  style={{
180
253
  flex: 1,
181
254
  padding: "8px 12px",
@@ -249,8 +322,13 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
249
322
  </button>
250
323
  )}
251
324
  <button
325
+ ref={launcherRef}
252
326
  onClick={() => setOpen((o) => !o)}
253
327
  aria-label={t("forge.ai_agent_widget.aria_open")}
328
+ aria-expanded={open}
329
+ aria-controls={open ? panelId : undefined}
330
+ aria-haspopup="dialog"
331
+ data-testid="forge-agent-launcher"
254
332
  style={{
255
333
  width: 56,
256
334
  height: 56,
@@ -422,3 +500,93 @@ function CallStatus({ text, color, alert }: { text: string; color?: string; aler
422
500
  </div>
423
501
  );
424
502
  }
503
+
504
+ /**
505
+ * A playbook choice as tappable buttons, or as cards when the options carry a
506
+ * length, a price, a picture or a description (live items such as services).
507
+ * A tap answers the choice; the picked option stays marked and the rest dim.
508
+ */
509
+ function ChoiceOptions({
510
+ choice,
511
+ picked,
512
+ disabled,
513
+ accent,
514
+ onAccent,
515
+ onPick,
516
+ }: {
517
+ choice: AgentChoiceEvent;
518
+ picked?: string;
519
+ disabled: boolean;
520
+ accent: string;
521
+ onAccent: string;
522
+ onPick: (value: string) => void;
523
+ }) {
524
+ const t = useForgeT();
525
+ const theme = useForgeTheme();
526
+ const asCards = choice.options.some((o) => o.priceText || o.durationText || o.imageUrl || o.description);
527
+
528
+ return (
529
+ <div
530
+ role="group"
531
+ aria-label={choice.question || t("forge.ai_agent_widget.choice_label")}
532
+ data-testid="forge-agent-choice"
533
+ style={{
534
+ alignSelf: "flex-start",
535
+ maxWidth: "92%",
536
+ display: "flex",
537
+ flexDirection: asCards ? "column" : "row",
538
+ flexWrap: asCards ? "nowrap" : "wrap",
539
+ gap: 6,
540
+ }}
541
+ >
542
+ {choice.options.map((o) => {
543
+ const isPicked = picked === o.value;
544
+ const base: CSSProperties = {
545
+ textAlign: "left",
546
+ fontSize: 14,
547
+ fontFamily: "inherit",
548
+ cursor: disabled ? "default" : "pointer",
549
+ borderRadius: theme.cornerRadius,
550
+ border: `1px solid ${isPicked ? accent : `${theme.colors.text}33`}`,
551
+ background: isPicked ? accent : theme.colors.background,
552
+ color: isPicked ? onAccent : theme.colors.text,
553
+ opacity: disabled && !isPicked ? 0.55 : 1,
554
+ transition: "border-color 120ms ease, opacity 120ms ease",
555
+ };
556
+ const details = [o.durationText, o.priceText].filter(Boolean).join(" · ");
557
+ return (
558
+ <button
559
+ key={o.value}
560
+ type="button"
561
+ disabled={disabled}
562
+ aria-pressed={isPicked}
563
+ onClick={() => onPick(o.value)}
564
+ data-testid="forge-agent-choice-option"
565
+ style={asCards ? { ...base, display: "flex", gap: 10, alignItems: "center", padding: 8 } : { ...base, padding: "6px 14px" }}
566
+ >
567
+ {asCards && o.imageUrl && (
568
+ <img
569
+ src={o.imageUrl}
570
+ alt=""
571
+ style={{ width: 48, height: 48, objectFit: "cover", borderRadius: Math.min(8, theme.cornerRadius), flexShrink: 0 }}
572
+ />
573
+ )}
574
+ {asCards ? (
575
+ <span style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
576
+ <span style={{ fontWeight: 600 }}>{o.label}</span>
577
+ {details && <span style={{ fontSize: 12, opacity: 0.75 }}>{details}</span>}
578
+ {o.description && (
579
+ <span style={{ fontSize: 12, opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>
580
+ {o.description}
581
+ </span>
582
+ )}
583
+ </span>
584
+ ) : (
585
+ o.label
586
+ )}
587
+ </button>
588
+ );
589
+ })}
590
+ </div>
591
+ );
592
+ }
@@ -2,8 +2,6 @@ import React, { useEffect, useState } from "react";
2
2
  import { useForm, Controller } from "react-hook-form";
3
3
  import { CreditCard, MapPin, Package, User } from "lucide-react";
4
4
  import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
5
- import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
6
- import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
7
5
  import type { CountryCode } from "libphonenumber-js";
8
6
  import { usePhoneInput } from "../headless/forms/usePhoneInput";
9
7
  import { PhoneInput } from "./PhoneInput";
@@ -18,6 +16,7 @@ import { Loading } from "./Loading";
18
16
  import { CartLineOptions } from "./CartLineOptions";
19
17
  import { HoldNotice } from "./HoldNotice";
20
18
  import { PaystackPayButton } from "./PaystackPayButton";
19
+ import { ShippingRateSelector } from "./ShippingRateSelector";
21
20
  import { useMetaInitiateCheckout } from "../analytics/useMetaEvents";
22
21
 
23
22
  export interface CheckoutProps {
@@ -1161,114 +1160,3 @@ function PaymentSection({ checkout, onBack }: { checkout: ReturnType<typeof useC
1161
1160
  </div>
1162
1161
  );
1163
1162
  }
1164
-
1165
- // ── Shipping rate selector ──────────────────────────────────────────────────────
1166
- /**
1167
- * Renders the shipping groups: fixed lines for auto-selected flat/external
1168
- * groups, and a radio list for the selectable (dynamic/carrier) group. Selection
1169
- * state is owned by `useCheckout`; this component just renders + calls `onSelect`.
1170
- */
1171
- function ShippingRateSelector({
1172
- groups,
1173
- selections,
1174
- onSelect,
1175
- formatCurrency,
1176
- }: {
1177
- groups: ShippingGroup[];
1178
- selections: Record<string, SelectedShippingRate>;
1179
- onSelect: (group: ShippingGroup, rate: ShippingRate) => void;
1180
- formatCurrency: (amount: number, currencyCode?: string) => string;
1181
- }) {
1182
- const theme = useForgeTheme();
1183
- const tr = useForgeT();
1184
- if (groups.length === 0) return null;
1185
-
1186
- return (
1187
- <div className="fg:space-y-6">
1188
- {groups.map((group) => (
1189
- <div
1190
- key={group.id}
1191
- className="fg:p-4 fg:rounded-lg"
1192
- style={{ border: `1px solid ${theme.colors.primary}${alpha(0.15)}`, borderRadius: `${theme.cornerRadius}px` }}
1193
- >
1194
- <p className="fg:font-medium fg:mb-3" style={{ color: theme.colors.text }}>
1195
- {group.title}
1196
- </p>
1197
-
1198
- {!group.selectable && group.rates.length > 0 ? (
1199
- // Flat / external — a single, auto-selected rate.
1200
- <div
1201
- className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
1202
- style={{
1203
- backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
1204
- borderRadius: `${theme.cornerRadius}px`,
1205
- }}
1206
- >
1207
- <div>
1208
- <span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
1209
- {group.rates[0].carrier} · {group.rates[0].service}
1210
- </span>
1211
- {group.rates[0].estimatedDays != null && (
1212
- <span className="fg:text-xs fg:ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
1213
- {tr("forge.checkout.rate_days", { days: group.rates[0].estimatedDays })}
1214
- </span>
1215
- )}
1216
- </div>
1217
- <span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
1218
- {formatCurrency(group.rates[0].amount, group.rates[0].currency)}
1219
- </span>
1220
- </div>
1221
- ) : (
1222
- // Dynamic — buyer picks a carrier option.
1223
- <div className="fg:space-y-2">
1224
- {group.rates.length === 0 && (
1225
- <p className="fg:text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
1226
- {tr("forge.checkout.no_shipping_options")}
1227
- </p>
1228
- )}
1229
- {group.rates.map((rate) => {
1230
- const isSelected = selections[group.id]?.rateId === rate.rateId;
1231
- return (
1232
- <label
1233
- key={rate.rateId}
1234
- className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md fg:cursor-pointer fg:transition-colors"
1235
- style={{
1236
- backgroundColor: isSelected ? `${theme.colors.primary}${alpha(0.1)}` : "transparent",
1237
- border: `1px solid ${isSelected ? theme.colors.primary : `${theme.colors.primary}${alpha(0.2)}`}`,
1238
- borderRadius: `${theme.cornerRadius}px`,
1239
- }}
1240
- >
1241
- <div className="fg:flex fg:items-center fg:gap-3">
1242
- <input
1243
- type="radio"
1244
- name={`shipping-rate-${group.id}`}
1245
- checked={isSelected}
1246
- onChange={() => onSelect(group, rate)}
1247
- style={{ accentColor: theme.colors.primary }}
1248
- />
1249
- <div>
1250
- <span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
1251
- {rate.carrier} - {rate.service}
1252
- </span>
1253
- {rate.estimatedDays && (
1254
- <p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
1255
- {rate.estimatedDays === 1
1256
- ? tr("forge.checkout.rate_estimated_day", { days: rate.estimatedDays })
1257
- : tr("forge.checkout.rate_estimated_days", { days: rate.estimatedDays })}
1258
- </p>
1259
- )}
1260
- </div>
1261
- </div>
1262
- <span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
1263
- {formatCurrency(rate.amount, rate.currency)}
1264
- </span>
1265
- </label>
1266
- );
1267
- })}
1268
- </div>
1269
- )}
1270
- </div>
1271
- ))}
1272
- </div>
1273
- );
1274
- }