@tribe-nest/forge 3.65.0 → 3.67.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/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +17 -0
- package/src/i18n/en.json +17 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- 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/index.ts +11 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
|
@@ -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
|
}
|
|
@@ -4,6 +4,7 @@ import { useForgeT } from "../../i18n";
|
|
|
4
4
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
5
5
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { readableTextOn } from "../theme/contrast";
|
|
7
|
+
import { useBrandLegalUrls } from "../../data/queries/useWebsite";
|
|
7
8
|
|
|
8
9
|
export interface CookieConsentProps {
|
|
9
10
|
/** Heading text. Defaults to Forge's own translated heading. */
|
|
@@ -17,19 +18,20 @@ export interface CookieConsentProps {
|
|
|
17
18
|
/** Link to the site's own privacy policy (rendered after the description). */
|
|
18
19
|
privacyPolicyHref?: string;
|
|
19
20
|
/**
|
|
20
|
-
* Fall back to
|
|
21
|
-
*
|
|
21
|
+
* Fall back to the brand's hosted privacy policy when the site doesn't have
|
|
22
|
+
* its own: TribeNest's, or Mandatera's for a law firm, from `site-config`
|
|
23
|
+
* (https://www.tribenest.co/privacy when the backend sends none).
|
|
22
24
|
* Ignored if `privacyPolicyHref` is set. Opens in a new tab.
|
|
23
25
|
*/
|
|
24
26
|
useTribeNestPrivacy?: boolean;
|
|
25
27
|
/**
|
|
26
28
|
* Controlled visibility. Leave undefined for the default behaviour (show once
|
|
27
29
|
* until the visitor decides). Set `true` to force it open again so a visitor
|
|
28
|
-
* can change their choice
|
|
30
|
+
* can change their choice: wire a "Cookie settings" link to a boolean state
|
|
29
31
|
* and pass it here (see the site-starter footer for the pattern).
|
|
30
32
|
*/
|
|
31
33
|
open?: boolean;
|
|
32
|
-
/** Called after the visitor makes a choice or dismisses
|
|
34
|
+
/** Called after the visitor makes a choice or dismisses. Use it to reset your
|
|
33
35
|
* controlled `open` state back to false. */
|
|
34
36
|
onClose?: () => void;
|
|
35
37
|
/** Extra class on the outer wrapper. */
|
|
@@ -42,8 +44,8 @@ const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Drop-in cookie-consent banner. Shows once until the visitor decides, writes to
|
|
45
|
-
* the shared consent store, and
|
|
46
|
-
* store
|
|
47
|
+
* the shared consent store, and (because `<ForgeAnalytics />` reads the same
|
|
48
|
+
* store) actually gates first-party analytics (performance) and the Meta Pixel
|
|
47
49
|
* (marketing). Themed via Forge tokens. Drop it into the site root:
|
|
48
50
|
* `<CookieConsent privacyPolicyHref="/privacy" />`.
|
|
49
51
|
*/
|
|
@@ -59,8 +61,10 @@ export function CookieConsent({
|
|
|
59
61
|
}: CookieConsentProps) {
|
|
60
62
|
const t = useThemeTokens();
|
|
61
63
|
const tr = useForgeT();
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
+
const brandLegal = useBrandLegalUrls();
|
|
65
|
+
const privacyHref =
|
|
66
|
+
privacyPolicyHref ?? (useTribeNestPrivacy ? (brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL) : undefined);
|
|
67
|
+
// The brand's hosted policy opens in a new tab; a same-origin custom page stays.
|
|
64
68
|
const privacyExternal = !privacyPolicyHref && useTribeNestPrivacy;
|
|
65
69
|
const { ready, decided, consent, acceptAll, rejectAll, save } = useCookieConsent();
|
|
66
70
|
const [draft, setDraft] = useState<Draft>({
|
|
@@ -121,7 +125,7 @@ export function CookieConsent({
|
|
|
121
125
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>
|
|
122
126
|
{title ?? tr("forge.cookie_consent.title")}
|
|
123
127
|
</h2>
|
|
124
|
-
{/* Close is only meaningful once a choice exists (reopened panel)
|
|
128
|
+
{/* Close is only meaningful once a choice exists (reopened panel);
|
|
125
129
|
on first run the visitor must pick an option. */}
|
|
126
130
|
{decided && (
|
|
127
131
|
<button
|
|
@@ -9,6 +9,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
|
9
9
|
import { PageActions } from "./PageActions";
|
|
10
10
|
import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
|
|
11
11
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
12
|
+
import { useBrandLegalUrls } from "../../data/queries/useWebsite";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* How each streaming service is presented.
|
|
@@ -57,7 +58,7 @@ const SERVICES: Record<string, { label: string; color: string; action?: string }
|
|
|
57
58
|
*/
|
|
58
59
|
const OVERLAY_Z_INDEX = 60;
|
|
59
60
|
|
|
60
|
-
/** TribeNest's hosted policy, matching
|
|
61
|
+
/** TribeNest's hosted policy, the last fallback, matching `CookieConsent`. */
|
|
61
62
|
const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
62
63
|
|
|
63
64
|
const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
|
|
@@ -69,7 +70,8 @@ export interface MusicLinkPageProps {
|
|
|
69
70
|
initialLink?: IMusicLink | null;
|
|
70
71
|
/** Heading above the service list. */
|
|
71
72
|
listHeading?: string;
|
|
72
|
-
/** The site's own privacy policy. Falls back to
|
|
73
|
+
/** The site's own privacy policy. Falls back to the brand's hosted one
|
|
74
|
+
* (TribeNest's, or Mandatera's for a law firm) from `site-config`. */
|
|
73
75
|
privacyHref?: string;
|
|
74
76
|
/** The "Powered by TribeNest" credit. */
|
|
75
77
|
showPoweredBy?: boolean;
|
|
@@ -375,7 +377,8 @@ function SmartLinkFooter({
|
|
|
375
377
|
showPoweredBy: boolean;
|
|
376
378
|
}) {
|
|
377
379
|
const { reopen } = useCookieConsent();
|
|
378
|
-
const
|
|
380
|
+
const brandLegal = useBrandLegalUrls();
|
|
381
|
+
const href = privacyHref ?? brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL;
|
|
379
382
|
const external = !privacyHref;
|
|
380
383
|
|
|
381
384
|
const linkStyle: CSSProperties = {
|