@tribe-nest/forge 3.65.0 → 3.69.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/useCheckoutLink.ts +70 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +42 -0
- package/src/i18n/en.json +42 -0
- package/src/index.ts +1 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/runtime/pages.ts +1 -0
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +78 -0
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CheckoutLinkPayment.tsx +303 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +231 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useCheckoutLink, useCheckoutLinkFinalize } from "../../../data/queries/useCheckoutLink";
|
|
4
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
5
|
+
|
|
6
|
+
export interface UseCheckoutLinkPaymentOptions {
|
|
7
|
+
/** Where the provider returns the buyer. Default: this page with `?finalise=1`. */
|
|
8
|
+
returnPath?: string;
|
|
9
|
+
/**
|
|
10
|
+
* True on the return leg (the provider sent the buyer back): the hook then
|
|
11
|
+
* settles the link instead of offering to pay.
|
|
12
|
+
*/
|
|
13
|
+
returning?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ApiError = { response?: { status?: number; data?: { message?: string; code?: string; details?: { status?: string } } } };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Headless checkout link payment: the hosted pay page `/i/checkout/pay/<token>`.
|
|
20
|
+
*
|
|
21
|
+
* Loads the link (`open`, which extends its hold to the payment window), starts
|
|
22
|
+
* its ONE payment only when the buyer presses Pay (`start-payment` runs the
|
|
23
|
+
* server's checks first: a closed link or a hold whose items were taken is
|
|
24
|
+
* refused before any payment exists), and on the return leg settles it
|
|
25
|
+
* (`finalize`). Stripe renders from `clientSecret`; Paystack opens its modal.
|
|
26
|
+
*/
|
|
27
|
+
export function useCheckoutLinkPayment(token?: string | null, opts: UseCheckoutLinkPaymentOptions = {}) {
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
const link = useCheckoutLink(opts.returning ? null : token);
|
|
30
|
+
const finalize = useCheckoutLinkFinalize(token, !!opts.returning);
|
|
31
|
+
|
|
32
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
33
|
+
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
|
|
34
|
+
const returnUrl = token ? `${origin}${opts.returnPath ?? pathname}?finalise=1` : "";
|
|
35
|
+
|
|
36
|
+
const flow = usePaymentFlow({
|
|
37
|
+
path: "/public/checkout-links/start-payment",
|
|
38
|
+
body: { token },
|
|
39
|
+
returnUrl,
|
|
40
|
+
autoStart: false,
|
|
41
|
+
enabled: !!token,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const [refusal, setRefusal] = useState<{ message: string; code: string | null; status: string | null } | null>(null);
|
|
45
|
+
|
|
46
|
+
const pay = useCallback(async () => {
|
|
47
|
+
setRefusal(null);
|
|
48
|
+
try {
|
|
49
|
+
await flow.start();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const data = (error as ApiError)?.response?.data;
|
|
52
|
+
setRefusal({
|
|
53
|
+
message: data?.message ?? "",
|
|
54
|
+
code: data?.code ?? null,
|
|
55
|
+
status: data?.details?.status ?? null,
|
|
56
|
+
});
|
|
57
|
+
// The link's own state may have moved (paid in another tab, expired).
|
|
58
|
+
await queryClient.invalidateQueries({ queryKey: ["checkout-link", token] });
|
|
59
|
+
}
|
|
60
|
+
}, [flow, queryClient, token]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
link,
|
|
64
|
+
finalize,
|
|
65
|
+
returnUrl,
|
|
66
|
+
pay,
|
|
67
|
+
isStarting: flow.isStarting,
|
|
68
|
+
clientSecret: flow.clientSecret,
|
|
69
|
+
isPaystack: flow.isPaystack,
|
|
70
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
71
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
72
|
+
/** The server's refusal of the last Pay, in its own words, with its code. */
|
|
73
|
+
refusal,
|
|
74
|
+
/** When the hold ends: the link's own deadline, as Pay last extended it. */
|
|
75
|
+
holdExpiresAt:
|
|
76
|
+
(flow.result as { link?: { expiresAt?: string } } | null)?.link?.expiresAt ?? link.data?.expiresAt ?? null,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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.
|
|
@@ -206,6 +208,17 @@ export { NewDmDialog, type NewDmDialogProps } from "./styled/chat/NewDmDialog";
|
|
|
206
208
|
export { AiAgentWidget, type AiAgentWidgetProps } from "./styled/AiAgentWidget";
|
|
207
209
|
export { Markdown } from "./styled/Markdown";
|
|
208
210
|
export { useAiAgent, type AiAgentMessage } from "./headless/agent/useAiAgent";
|
|
211
|
+
// "Call us". The room hook (`useAgentCallRoom`) is deliberately not exported
|
|
212
|
+
// here: it imports the media SDK, and this entry is on every page. A custom
|
|
213
|
+
// call UI imports the media hooks from `@tribe-nest/forge/media` itself.
|
|
214
|
+
export {
|
|
215
|
+
useAgentCall,
|
|
216
|
+
classifyAgentCallError,
|
|
217
|
+
type AgentCallState,
|
|
218
|
+
type AgentCallErrorKind,
|
|
219
|
+
type UseAgentCallOptions,
|
|
220
|
+
} from "./headless/agent/useAgentCall";
|
|
221
|
+
export { solveCallChallenge, leadingZeroBits, type SolveCallChallengeOptions } from "./headless/agent/callChallenge";
|
|
209
222
|
export { InvoicePayment, type InvoicePaymentProps } from "./styled/InvoicePayment";
|
|
210
223
|
export { PaymentLinkPayment, type PaymentLinkPaymentProps } from "./styled/PaymentLinkPayment";
|
|
211
224
|
export { BlogList, type BlogListProps } from "./styled/BlogList";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
3
|
+
import { MediaRoomProvider } from "../media";
|
|
4
|
+
import { useAgentAudio, useAgentCallRoom, type AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
|
|
5
|
+
|
|
6
|
+
export interface AiAgentCallRoomProps {
|
|
7
|
+
getCredentials: () => Promise<{ mediaUrl: string; token: string }>;
|
|
8
|
+
/** This browser's identity in the room, from the start response. */
|
|
9
|
+
selfIdentity: string;
|
|
10
|
+
/** The agent's name, for the status line. */
|
|
11
|
+
agentName: string;
|
|
12
|
+
/** The room says the call is over: the agent left, or the room closed. */
|
|
13
|
+
onEnded: () => void;
|
|
14
|
+
/** Told every status change, so the widget can style around it. */
|
|
15
|
+
onStatus?: (status: AgentCallRoomStatus) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The live half of "Call us": the media room, the microphone out and the
|
|
20
|
+
* agent's voice in.
|
|
21
|
+
*
|
|
22
|
+
* A DEFAULT export on purpose. `AiAgentWidget` is on every page of every site
|
|
23
|
+
* and reaches this through `React.lazy(() => import(...))`, so the media SDK
|
|
24
|
+
* and mediasoup are fetched only by a visitor who actually presses Call.
|
|
25
|
+
*/
|
|
26
|
+
export default function AiAgentCallRoom({ getCredentials, ...rest }: AiAgentCallRoomProps): ReactNode {
|
|
27
|
+
const t = useForgeT();
|
|
28
|
+
return (
|
|
29
|
+
<MediaRoomProvider
|
|
30
|
+
getCredentials={getCredentials}
|
|
31
|
+
fallback={<StatusLine text={t("forge.ai_agent_call_room.connecting")} />}
|
|
32
|
+
>
|
|
33
|
+
<LiveCall {...rest} />
|
|
34
|
+
</MediaRoomProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function LiveCall({ selfIdentity, agentName, onEnded, onStatus }: Omit<AiAgentCallRoomProps, "getCredentials">) {
|
|
39
|
+
const t = useForgeT();
|
|
40
|
+
const { status, agentAudioProducerIds, setAgentSpeaking } = useAgentCallRoom({ selfIdentity, onEnded });
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
onStatus?.(status);
|
|
44
|
+
}, [status, onStatus]);
|
|
45
|
+
|
|
46
|
+
const text =
|
|
47
|
+
status === "no_mic"
|
|
48
|
+
? t("forge.ai_agent_call_room.no_mic")
|
|
49
|
+
: status === "speaking"
|
|
50
|
+
? t("forge.ai_agent_call_room.speaking", { name: agentName })
|
|
51
|
+
: status === "listening"
|
|
52
|
+
? t("forge.ai_agent_call_room.listening", { name: agentName })
|
|
53
|
+
: t("forge.ai_agent_call_room.connecting");
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{agentAudioProducerIds.map((id) => (
|
|
58
|
+
<AgentAudio key={id} producerId={id} onSpeaking={setAgentSpeaking} />
|
|
59
|
+
))}
|
|
60
|
+
<StatusLine text={text} alert={status === "no_mic"} />
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AgentAudio({ producerId, onSpeaking }: { producerId: string; onSpeaking: (speaking: boolean) => void }) {
|
|
66
|
+
const attach = useAgentAudio(producerId, onSpeaking);
|
|
67
|
+
return <audio ref={attach} autoPlay data-testid="forge-agent-call-audio" />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function StatusLine({ text, alert }: { text: string; alert?: boolean }) {
|
|
71
|
+
return (
|
|
72
|
+
<div role={alert ? "alert" : "status"} aria-live="polite" style={{ fontSize: 14, textAlign: "center" }} data-testid="forge-agent-call-status">
|
|
73
|
+
{text}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Phone, PhoneOff } from "lucide-react";
|
|
2
3
|
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { readableTextOn } from "../theme/contrast";
|
|
5
6
|
import { useAiAgent } from "../headless/agent/useAiAgent";
|
|
7
|
+
import { useAgentCall, type AgentCallErrorKind } from "../headless/agent/useAgentCall";
|
|
8
|
+
import type { AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
|
|
6
9
|
import { Markdown } from "./Markdown";
|
|
7
10
|
|
|
11
|
+
// The live call pulls in the media SDK and mediasoup, which no visitor needs
|
|
12
|
+
// until they press Call. Split out so it stays out of every page's bundle.
|
|
13
|
+
const loadCallRoom = () => import("./AiAgentCallRoom");
|
|
14
|
+
const AiAgentCallRoom = lazy(loadCallRoom);
|
|
15
|
+
|
|
16
|
+
/** The widget already draws errors in this red; hang up shares it. */
|
|
17
|
+
const DANGER = "#c0392b";
|
|
18
|
+
|
|
19
|
+
const CALL_ERROR_KEYS: Record<AgentCallErrorKind, string> = {
|
|
20
|
+
too_many: "forge.ai_agent_widget.call_error_too_many",
|
|
21
|
+
unavailable: "forge.ai_agent_widget.call_error_unavailable",
|
|
22
|
+
busy: "forge.ai_agent_widget.call_error_busy",
|
|
23
|
+
check_failed: "forge.ai_agent_widget.call_error_check_failed",
|
|
24
|
+
failed: "forge.ai_agent_widget.call_error_failed",
|
|
25
|
+
};
|
|
26
|
+
|
|
8
27
|
export interface AiAgentWidgetProps {
|
|
9
28
|
/** Corner to anchor the floating bubble. Default "bottom-right". */
|
|
10
29
|
position?: "bottom-right" | "bottom-left";
|
|
@@ -24,12 +43,24 @@ export interface AiAgentWidgetProps {
|
|
|
24
43
|
* the profile, or when the visitor is on a page the artist excluded.
|
|
25
44
|
* Appearance-only props let authors restyle it without touching behavior. For
|
|
26
45
|
* custom UI, use the `useAiAgent()` hook instead.
|
|
46
|
+
*
|
|
47
|
+
* When the agent takes calls, a phone button sits beside the bubble ("Call
|
|
48
|
+
* us"): a voice call with the same agent, in the browser. `useAgentCall()` is
|
|
49
|
+
* its headless half.
|
|
27
50
|
*/
|
|
28
51
|
export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
|
|
29
52
|
const t = useForgeT();
|
|
30
53
|
const theme = useForgeTheme();
|
|
31
|
-
const { isEnabled, config, messages, sending, error, send } = useAiAgent();
|
|
32
|
-
const
|
|
54
|
+
const { isEnabled, canCall, config, messages, sending, error, send, visitorId } = useAiAgent();
|
|
55
|
+
const call = useAgentCall({ visitorId });
|
|
56
|
+
const [view, setView] = useState<"chat" | "call" | null>(null);
|
|
57
|
+
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
|
+
});
|
|
63
|
+
const [roomStatus, setRoomStatus] = useState<AgentCallRoomStatus>("connecting");
|
|
33
64
|
const [input, setInput] = useState("");
|
|
34
65
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
35
66
|
|
|
@@ -42,8 +73,29 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
42
73
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
|
43
74
|
}, [messages, sending]);
|
|
44
75
|
|
|
76
|
+
const callPhase = call.state.phase;
|
|
77
|
+
const callActive = callPhase === "connecting" || callPhase === "live";
|
|
78
|
+
|
|
79
|
+
// On a page where the widget hides (an excluded path) the call panel goes
|
|
80
|
+
// with it, and the call would run on with no way to hang up. Hang up instead.
|
|
81
|
+
const { end: endCall } = call;
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!isEnabled || !canCall) endCall();
|
|
84
|
+
}, [isEnabled, canCall, endCall]);
|
|
85
|
+
|
|
86
|
+
const startCall = () => {
|
|
87
|
+
void loadCallRoom();
|
|
88
|
+
setView("call");
|
|
89
|
+
if (callActive) return;
|
|
90
|
+
setRoomStatus("connecting");
|
|
91
|
+
void call.start();
|
|
92
|
+
};
|
|
93
|
+
const onRoomStatus = useCallback((status: AgentCallRoomStatus) => setRoomStatus(status), []);
|
|
94
|
+
|
|
45
95
|
if (!isEnabled) return null;
|
|
46
96
|
|
|
97
|
+
const agentName = config?.displayName || heading;
|
|
98
|
+
|
|
47
99
|
const submit = () => {
|
|
48
100
|
if (!input.trim()) return;
|
|
49
101
|
send(input);
|
|
@@ -146,24 +198,227 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
146
198
|
</div>
|
|
147
199
|
)}
|
|
148
200
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
{/* Kept mounted, only hidden, while a call is on: unmounting it would
|
|
202
|
+
leave the media room with the call still running. */}
|
|
203
|
+
{(view === "call" || callActive) && (
|
|
204
|
+
<CallPanel
|
|
205
|
+
hidden={view !== "call"}
|
|
206
|
+
heading={heading}
|
|
207
|
+
agentName={agentName}
|
|
208
|
+
accent={accent}
|
|
209
|
+
onAccent={onAccent}
|
|
210
|
+
callState={call.state}
|
|
211
|
+
roomStatus={roomStatus}
|
|
212
|
+
onRoomStatus={onRoomStatus}
|
|
213
|
+
getCredentials={call.getCredentials}
|
|
214
|
+
onHangUp={call.end}
|
|
215
|
+
onRetry={startCall}
|
|
216
|
+
onClose={() => {
|
|
217
|
+
call.reset();
|
|
218
|
+
setView(null);
|
|
219
|
+
}}
|
|
220
|
+
onChat={() => {
|
|
221
|
+
call.reset();
|
|
222
|
+
setView("chat");
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
<div style={{ display: "flex", gap: 12, alignItems: "center", justifyContent: position === "bottom-left" ? "flex-start" : "flex-end" }}>
|
|
228
|
+
{canCall && (
|
|
229
|
+
<button
|
|
230
|
+
onClick={startCall}
|
|
231
|
+
aria-label={callActive ? t("forge.ai_agent_widget.aria_show_call") : t("forge.ai_agent_widget.aria_call")}
|
|
232
|
+
data-testid="forge-agent-call-button"
|
|
233
|
+
style={{
|
|
234
|
+
width: 48,
|
|
235
|
+
height: 48,
|
|
236
|
+
borderRadius: "50%",
|
|
237
|
+
background: callActive ? DANGER : theme.colors.background,
|
|
238
|
+
color: callActive ? readableTextOn(DANGER) : accent,
|
|
239
|
+
border: `2px solid ${callActive ? DANGER : accent}`,
|
|
240
|
+
cursor: "pointer",
|
|
241
|
+
boxShadow: "0 6px 20px rgba(0,0,0,0.18)",
|
|
242
|
+
display: "inline-flex",
|
|
243
|
+
alignItems: "center",
|
|
244
|
+
justifyContent: "center",
|
|
245
|
+
order: position === "bottom-left" ? 2 : 0,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<Phone size={20} aria-hidden />
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
<button
|
|
252
|
+
onClick={() => setOpen((o) => !o)}
|
|
253
|
+
aria-label={t("forge.ai_agent_widget.aria_open")}
|
|
254
|
+
style={{
|
|
255
|
+
width: 56,
|
|
256
|
+
height: 56,
|
|
257
|
+
borderRadius: "50%",
|
|
258
|
+
background: accent,
|
|
259
|
+
color: onAccent,
|
|
260
|
+
border: "none",
|
|
261
|
+
cursor: "pointer",
|
|
262
|
+
boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
|
|
263
|
+
fontSize: 24,
|
|
264
|
+
order: 1,
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{open ? "×" : "💬"}
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface CallPanelProps {
|
|
275
|
+
hidden: boolean;
|
|
276
|
+
heading: string;
|
|
277
|
+
agentName: string;
|
|
278
|
+
accent: string;
|
|
279
|
+
onAccent: string;
|
|
280
|
+
callState: ReturnType<typeof useAgentCall>["state"];
|
|
281
|
+
roomStatus: AgentCallRoomStatus;
|
|
282
|
+
onRoomStatus: (status: AgentCallRoomStatus) => void;
|
|
283
|
+
getCredentials: () => Promise<{ mediaUrl: string; token: string }>;
|
|
284
|
+
onHangUp: () => void;
|
|
285
|
+
onRetry: () => void;
|
|
286
|
+
onClose: () => void;
|
|
287
|
+
onChat: () => void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** "Call us": the call's own card, in place of the chat panel. */
|
|
291
|
+
function CallPanel({
|
|
292
|
+
hidden,
|
|
293
|
+
heading,
|
|
294
|
+
agentName,
|
|
295
|
+
accent,
|
|
296
|
+
onAccent,
|
|
297
|
+
callState,
|
|
298
|
+
roomStatus,
|
|
299
|
+
onRoomStatus,
|
|
300
|
+
getCredentials,
|
|
301
|
+
onHangUp,
|
|
302
|
+
onRetry,
|
|
303
|
+
onClose,
|
|
304
|
+
onChat,
|
|
305
|
+
}: CallPanelProps) {
|
|
306
|
+
const t = useForgeT();
|
|
307
|
+
const theme = useForgeTheme();
|
|
308
|
+
const active = callState.phase === "connecting" || callState.phase === "live";
|
|
309
|
+
const speaking = callState.phase === "live" && roomStatus === "speaking";
|
|
310
|
+
|
|
311
|
+
const secondary: CSSProperties = {
|
|
312
|
+
background: "transparent",
|
|
313
|
+
color: theme.colors.text,
|
|
314
|
+
border: `1px solid ${theme.colors.text}33`,
|
|
315
|
+
borderRadius: theme.cornerRadius,
|
|
316
|
+
padding: "8px 14px",
|
|
317
|
+
cursor: "pointer",
|
|
318
|
+
fontSize: 14,
|
|
319
|
+
};
|
|
320
|
+
const primary: CSSProperties = { ...secondary, background: accent, color: onAccent, border: "none", fontWeight: 600 };
|
|
321
|
+
|
|
322
|
+
let body: ReactNode;
|
|
323
|
+
if (callState.phase === "live") {
|
|
324
|
+
body = (
|
|
325
|
+
<Suspense fallback={<CallStatus text={t("forge.ai_agent_widget.call_connecting")} />}>
|
|
326
|
+
<AiAgentCallRoom
|
|
327
|
+
getCredentials={getCredentials}
|
|
328
|
+
selfIdentity={callState.call.identity}
|
|
329
|
+
agentName={agentName}
|
|
330
|
+
onEnded={onHangUp}
|
|
331
|
+
onStatus={onRoomStatus}
|
|
332
|
+
/>
|
|
333
|
+
</Suspense>
|
|
334
|
+
);
|
|
335
|
+
} else if (callState.phase === "ended") {
|
|
336
|
+
body = <CallStatus text={t("forge.ai_agent_widget.call_ended")} />;
|
|
337
|
+
} else if (callState.phase === "error") {
|
|
338
|
+
body = <CallStatus text={t(CALL_ERROR_KEYS[callState.error])} color={DANGER} alert />;
|
|
339
|
+
} else {
|
|
340
|
+
body = <CallStatus text={t("forge.ai_agent_widget.call_connecting")} />;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
data-testid="forge-agent-call-panel"
|
|
346
|
+
data-phase={callState.phase}
|
|
347
|
+
style={{
|
|
348
|
+
width: "min(380px, calc(100vw - 40px))",
|
|
349
|
+
marginBottom: 12,
|
|
350
|
+
display: hidden ? "none" : "flex",
|
|
351
|
+
flexDirection: "column",
|
|
352
|
+
background: theme.colors.background,
|
|
353
|
+
color: theme.colors.text,
|
|
354
|
+
border: `1px solid ${theme.colors.text}1a`,
|
|
355
|
+
borderRadius: theme.cornerRadius,
|
|
356
|
+
overflow: "hidden",
|
|
357
|
+
boxShadow: "0 12px 40px rgba(0,0,0,0.18)",
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
<div style={{ background: accent, color: onAccent, padding: "12px 16px", fontWeight: 600, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
361
|
+
<span>{heading}</span>
|
|
362
|
+
{!active && (
|
|
363
|
+
<button onClick={onClose} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label={t("forge.ai_agent_widget.aria_close")}>
|
|
364
|
+
×
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<div style={{ padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
|
|
370
|
+
<div
|
|
371
|
+
aria-hidden
|
|
372
|
+
style={{
|
|
373
|
+
width: 64,
|
|
374
|
+
height: 64,
|
|
375
|
+
borderRadius: "50%",
|
|
376
|
+
background: accent,
|
|
377
|
+
color: onAccent,
|
|
378
|
+
display: "flex",
|
|
379
|
+
alignItems: "center",
|
|
380
|
+
justifyContent: "center",
|
|
381
|
+
transform: speaking ? "scale(1.12)" : "scale(1)",
|
|
382
|
+
boxShadow: speaking ? `0 0 0 8px ${accent}33` : "none",
|
|
383
|
+
transition: "transform 150ms ease, box-shadow 150ms ease",
|
|
384
|
+
opacity: active || callState.phase === "ended" ? 1 : 0.6,
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
<Phone size={28} />
|
|
388
|
+
</div>
|
|
389
|
+
<div style={{ fontWeight: 600 }}>{agentName}</div>
|
|
390
|
+
{body}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div style={{ display: "flex", gap: 8, padding: 12, justifyContent: "center", borderTop: `1px solid ${theme.colors.text}1a` }}>
|
|
394
|
+
{active ? (
|
|
395
|
+
<button
|
|
396
|
+
onClick={onHangUp}
|
|
397
|
+
data-testid="forge-agent-call-hangup"
|
|
398
|
+
style={{ ...primary, background: DANGER, color: readableTextOn(DANGER), display: "inline-flex", alignItems: "center", gap: 8 }}
|
|
399
|
+
>
|
|
400
|
+
<PhoneOff size={16} aria-hidden />
|
|
401
|
+
{t("forge.ai_agent_widget.call_hang_up")}
|
|
402
|
+
</button>
|
|
403
|
+
) : (
|
|
404
|
+
<>
|
|
405
|
+
<button onClick={onRetry} style={primary} data-testid="forge-agent-call-again">
|
|
406
|
+
{callState.phase === "error" ? t("forge.ai_agent_widget.call_try_again") : t("forge.ai_agent_widget.call_again")}
|
|
407
|
+
</button>
|
|
408
|
+
<button onClick={onChat} style={secondary}>
|
|
409
|
+
{t("forge.ai_agent_widget.call_back_to_chat")}
|
|
410
|
+
</button>
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function CallStatus({ text, color, alert }: { text: string; color?: string; alert?: boolean }) {
|
|
419
|
+
return (
|
|
420
|
+
<div role={alert ? "alert" : "status"} aria-live="polite" style={{ fontSize: 14, textAlign: "center", color }} data-testid="forge-agent-call-status">
|
|
421
|
+
{text}
|
|
167
422
|
</div>
|
|
168
423
|
);
|
|
169
424
|
}
|