chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
51
web/components/support-chat/chat-error-boundary.tsx
Normal file
51
web/components/support-chat/chat-error-boundary.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { RotateCwIcon } from "lucide-react"
|
||||
import { Component, type ReactNode } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
function ChatCrashFallback({ onReset }: { onReset: () => void }) {
|
||||
const t = useI18n()
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 bg-card px-6 text-center"
|
||||
>
|
||||
<div className="text-sm font-semibold text-foreground">{t("supportChat.crashTitle")}</div>
|
||||
<p className="max-w-[260px] text-xs leading-5 text-muted-foreground">
|
||||
{t("supportChat.crashDescription")}
|
||||
</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onReset}>
|
||||
<RotateCwIcon className="size-4" />
|
||||
{t("supportChat.crashReload")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type State = { failed: boolean }
|
||||
|
||||
/**
|
||||
* Keeps a rendering failure inside the chat from blanking the whole embedded
|
||||
* frame; the visitor gets a recoverable state instead.
|
||||
*/
|
||||
export class SupportChatErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { failed: false }
|
||||
|
||||
static getDerivedStateFromError(): State {
|
||||
return { failed: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error("Support chat crashed", error)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.failed) {
|
||||
return <ChatCrashFallback onReset={() => this.setState({ failed: false })} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
439
web/components/support-chat/chat-shell.tsx
Normal file
439
web/components/support-chat/chat-shell.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
MessageSquarePlusIcon,
|
||||
RotateCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { SupportChatErrorBoundary } from "@/components/support-chat/chat-error-boundary"
|
||||
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
|
||||
import { SupportChatConversationSwitcher } from "@/components/support-chat/conversation-switcher"
|
||||
import { SupportChatNewRequestDialog } from "@/components/support-chat/new-request-dialog"
|
||||
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
|
||||
import { CSATCard } from "@/components/support-chat/csat-card"
|
||||
import { CustomerMessageEditor } from "@/components/support-chat/customer-message-editor"
|
||||
import { getCustomerSessionToken } from "@/lib/api/im"
|
||||
import {
|
||||
SupportChatMessageList,
|
||||
type SupportChatMessageListHandle,
|
||||
} from "@/components/support-chat/message-list"
|
||||
import {
|
||||
bindSupportHostBridge,
|
||||
requestSupportHostMinimize,
|
||||
} from "@/lib/support-host-bridge"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
const windowActionButtonClass =
|
||||
"size-8 rounded-full border-0 bg-transparent text-muted-foreground shadow-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-primary/20 dark:hover:bg-white/10"
|
||||
|
||||
function WindowActionButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(windowActionButtonClass, className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useSupportChatSystemTheme() {
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
const root = document.documentElement
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const previousDarkClass = root.classList.contains("dark")
|
||||
const previousColorScheme = root.style.colorScheme
|
||||
|
||||
const syncTheme = () => {
|
||||
const isDark = query.matches
|
||||
root.classList.toggle("dark", isDark)
|
||||
root.style.colorScheme = isDark ? "dark" : "light"
|
||||
}
|
||||
|
||||
syncTheme()
|
||||
query.addEventListener("change", syncTheme)
|
||||
|
||||
return () => {
|
||||
query.removeEventListener("change", syncTheme)
|
||||
root.classList.toggle("dark", previousDarkClass)
|
||||
root.style.colorScheme = previousColorScheme
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function isEmbeddedInHost() {
|
||||
if (typeof window === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return window.parent !== window
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat panel without any outer sizing. Both the embedded iframe frame and
|
||||
* the standalone support page render this, so the surrounding chrome decides
|
||||
* how much room it gets.
|
||||
*/
|
||||
function SupportChatPanelContent() {
|
||||
const t = useI18n()
|
||||
useSupportChatSystemTheme()
|
||||
|
||||
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
|
||||
const [isEmbedded, setIsEmbedded] = useState(false)
|
||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
|
||||
const [isClosingConversation, setIsClosingConversation] = useState(false)
|
||||
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
themeColor,
|
||||
conversation,
|
||||
messages,
|
||||
messagesHasMore,
|
||||
messagesLoadingMore,
|
||||
loadOlderMessages,
|
||||
status,
|
||||
error,
|
||||
isOpen,
|
||||
isVisible,
|
||||
setIsOpen,
|
||||
setIsVisible,
|
||||
bootstrap,
|
||||
handleSendMessage,
|
||||
uploadMessageImage,
|
||||
sendAttachment,
|
||||
retry,
|
||||
disconnectSocket,
|
||||
markConversationRead,
|
||||
closeConversation,
|
||||
setConversationSatisfaction,
|
||||
} = useSupportChatStore(
|
||||
useShallow((state) => ({
|
||||
title: state.title,
|
||||
subtitle: state.subtitle,
|
||||
themeColor: state.themeColor,
|
||||
conversation: state.conversation,
|
||||
messages: state.messages,
|
||||
messagesHasMore: state.messagesHasMore,
|
||||
messagesLoadingMore: state.messagesLoadingMore,
|
||||
loadOlderMessages: state.loadOlderMessages,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
isOpen: state.isOpen,
|
||||
isVisible: state.isVisible,
|
||||
setIsOpen: state.setIsOpen,
|
||||
setIsVisible: state.setIsVisible,
|
||||
bootstrap: state.bootstrap,
|
||||
handleSendMessage: state.handleSendMessage,
|
||||
uploadMessageImage: state.uploadMessageImage,
|
||||
sendAttachment: state.sendAttachment,
|
||||
retry: state.retry,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
markConversationRead: state.markConversationRead,
|
||||
closeConversation: state.closeConversation,
|
||||
setConversationSatisfaction: state.setConversationSatisfaction,
|
||||
}))
|
||||
)
|
||||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
useEffect(() => {
|
||||
setIsEmbedded(isEmbeddedInHost())
|
||||
}, [])
|
||||
|
||||
const maybeMarkConversationRead = useCallback(() => {
|
||||
if (!isVisible || !conversation || typeof document === "undefined") {
|
||||
return
|
||||
}
|
||||
if (document.visibilityState !== "visible") {
|
||||
return
|
||||
}
|
||||
void markConversationRead().catch((readError) => {
|
||||
console.error("Failed to mark support chat conversation read", readError)
|
||||
})
|
||||
}, [conversation?.id, isVisible, markConversationRead])
|
||||
|
||||
useEffect(() => {
|
||||
return bindSupportHostBridge({
|
||||
onInit: () => {
|
||||
if (useSupportChatStore.getState().isOpen) {
|
||||
bootstrap()
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
setIsOpen(true)
|
||||
setIsVisible(true)
|
||||
},
|
||||
onMinimize: () => {
|
||||
setIsVisible(false)
|
||||
},
|
||||
})
|
||||
}, [bootstrap, setIsOpen, setIsVisible])
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap()
|
||||
|
||||
return () => {
|
||||
if (!isOpen) {
|
||||
disconnectSocket()
|
||||
}
|
||||
}
|
||||
}, [isOpen, bootstrap, disconnectSocket])
|
||||
|
||||
useEffect(() => {
|
||||
maybeMarkConversationRead()
|
||||
}, [maybeMarkConversationRead, safeMessages.length])
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
maybeMarkConversationRead()
|
||||
}
|
||||
}
|
||||
const handleFocus = () => {
|
||||
maybeMarkConversationRead()
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
}
|
||||
}, [maybeMarkConversationRead])
|
||||
|
||||
async function handleSend(content: string) {
|
||||
await handleSendMessage(content)
|
||||
messageListRef.current?.scrollToBottom()
|
||||
}
|
||||
|
||||
function handleCloseWindow() {
|
||||
setIsVisible(false)
|
||||
if (isEmbedded) {
|
||||
requestSupportHostMinimize()
|
||||
} else {
|
||||
window.location.replace(getStandaloneClosedUrl())
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseConversation() {
|
||||
if (isClosingConversation) {
|
||||
return
|
||||
}
|
||||
setIsClosingConversation(true)
|
||||
try {
|
||||
if (conversation?.id) {
|
||||
await closeConversation()
|
||||
}
|
||||
setIsCloseDialogOpen(false)
|
||||
} catch (closeError) {
|
||||
window.alert(closeError instanceof Error ? closeError.message : t("supportChat.closeConversationFailed"))
|
||||
} finally {
|
||||
setIsClosingConversation(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloseDialogOpen) {
|
||||
return
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isClosingConversation) {
|
||||
setIsCloseDialogOpen(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isCloseDialogOpen, isClosingConversation])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-card text-card-foreground"
|
||||
style={{ "--primary": themeColor } as CSSProperties}
|
||||
>
|
||||
<header className="shrink-0 border-b border-border/70 bg-card/95 px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,0.02)] backdrop-blur dark:border-border/60 dark:bg-card/90 sm:px-4">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<SupportChatConversationSwitcher
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
status={status}
|
||||
onNewRequest={() => setIsNewRequestDialogOpen(true)}
|
||||
onCloseActiveRequest={() => setIsCloseDialogOpen(true)}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<WindowActionButton
|
||||
onClick={() => setIsNewRequestDialogOpen(true)}
|
||||
aria-label={t("supportChat.newRequest")}
|
||||
title={t("supportChat.newRequest")}
|
||||
>
|
||||
<MessageSquarePlusIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
{status !== "connected" ? (
|
||||
<WindowActionButton
|
||||
onClick={retry}
|
||||
aria-label={t("supportChat.retry")}
|
||||
title={t("supportChat.retry")}
|
||||
>
|
||||
<RotateCwIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
) : null}
|
||||
{conversation && conversation.status !== 4 ? (
|
||||
<WindowActionButton
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
aria-label={t("supportChat.confirmClose")}
|
||||
title={t("supportChat.confirmClose")}
|
||||
className="hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
>
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
</WindowActionButton>
|
||||
) : null}
|
||||
<WindowActionButton
|
||||
onClick={handleCloseWindow}
|
||||
aria-label={t("supportChat.closeWindow")}
|
||||
title={t("supportChat.closeWindow")}
|
||||
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
|
||||
<SupportChatMessageList
|
||||
ref={messageListRef}
|
||||
messages={safeMessages}
|
||||
onNearBottomVisible={maybeMarkConversationRead}
|
||||
hasMoreOlder={messagesHasMore}
|
||||
loadingOlder={messagesLoadingMore}
|
||||
onLoadOlder={loadOlderMessages}
|
||||
/>
|
||||
<div className="shrink-0 border-t border-border/80 bg-card/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_24px_rgba(15,23,42,0.05)] dark:bg-card/90 dark:shadow-none">
|
||||
{conversation && (conversation.status === 4 || conversation.satisfaction) ? (
|
||||
<div className="border-b border-border/60 p-3">
|
||||
<CSATCard
|
||||
key={conversation.id}
|
||||
conversationId={conversation.id}
|
||||
customerToken={getCustomerSessionToken()}
|
||||
initialSatisfaction={conversation.satisfaction}
|
||||
onSubmitted={setConversationSatisfaction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CustomerMessageEditor
|
||||
disabled={!conversation || conversation.status === 4}
|
||||
onSend={handleSend}
|
||||
onUploadImage={uploadMessageImage}
|
||||
onSendAttachment={sendAttachment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="border-t border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
open={isCloseDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isClosingConversation) {
|
||||
setIsCloseDialogOpen(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("supportChat.closeDialogTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-5">
|
||||
{t("supportChat.closeDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => setIsCloseDialogOpen(false)}
|
||||
>
|
||||
{t("supportChat.continueConversation")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => void confirmCloseConversation()}
|
||||
>
|
||||
{isClosingConversation ? t("supportChat.closing") : t("supportChat.confirmClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SupportChatNewRequestDialog
|
||||
open={isNewRequestDialogOpen}
|
||||
onOpenChange={setIsNewRequestDialogOpen}
|
||||
onCreated={() => {
|
||||
messageListRef.current?.scrollToBottom()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SupportChatPanel() {
|
||||
return (
|
||||
<SupportChatErrorBoundary>
|
||||
<SupportChatPanelContent />
|
||||
</SupportChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded widget frame. The host sizes the iframe, so the panel always
|
||||
* fills the whole document box.
|
||||
*/
|
||||
export function SupportChatShell() {
|
||||
return (
|
||||
<main className="relative flex h-[100dvh] min-h-[100dvh] overflow-hidden bg-muted text-foreground supports-not-[height:100dvh]:h-screen supports-not-[height:100dvh]:min-h-screen">
|
||||
<SupportChatPanel />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
29
web/components/support-chat/close-navigation.test.mjs
Normal file
29
web/components/support-chat/close-navigation.test.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadCloseNavigation() {
|
||||
const source = await readFile(new URL("./close-navigation.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "close-navigation.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("standalone close navigates to the non-bootstrapping closed page", async () => {
|
||||
const { getStandaloneClosedUrl } = await loadCloseNavigation()
|
||||
|
||||
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed")
|
||||
})
|
||||
5
web/components/support-chat/close-navigation.ts
Normal file
5
web/components/support-chat/close-navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return SUPPORT_CHAT_CLOSED_PATH
|
||||
}
|
||||
38
web/components/support-chat/connection-status.tsx
Normal file
38
web/components/support-chat/connection-status.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type SupportChatConnectionStatusProps = {
|
||||
status: "connecting" | "connected" | "disconnected"
|
||||
}
|
||||
|
||||
export function SupportChatConnectionStatus({ status }: SupportChatConnectionStatusProps) {
|
||||
const t = useI18n()
|
||||
const toneClass =
|
||||
status === "connected"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/50 dark:text-emerald-300"
|
||||
: status === "connecting"
|
||||
? "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"
|
||||
: "border-border bg-muted text-muted-foreground"
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-6 gap-2 px-2.5 text-[11px] font-medium shadow-sm", toneClass)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
status === "connected"
|
||||
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
|
||||
: status === "connecting"
|
||||
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
|
||||
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
|
||||
)}
|
||||
/>
|
||||
<span>{t(`supportChat.${status}`)}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
21
web/components/support-chat/conversation-switcher.test.mjs
Normal file
21
web/components/support-chat/conversation-switcher.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
const source = await readFile(
|
||||
new URL("./conversation-switcher.tsx", import.meta.url),
|
||||
"utf8"
|
||||
)
|
||||
|
||||
describe("support chat conversation switcher", () => {
|
||||
it("imports DropdownMenuGroup from dropdown-menu primitives", () => {
|
||||
assert.match(source, /DropdownMenuGroup/)
|
||||
})
|
||||
|
||||
it("wraps DropdownMenuLabel inside DropdownMenuGroup to satisfy Base UI context (Error #31 prevention)", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/<DropdownMenuGroup>[\s\S]*<DropdownMenuLabel[\s\S]*<\/DropdownMenuLabel>[\s\S]*<\/DropdownMenuGroup>/
|
||||
)
|
||||
})
|
||||
})
|
||||
262
web/components/support-chat/conversation-switcher.tsx
Normal file
262
web/components/support-chat/conversation-switcher.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
HeadphonesIcon,
|
||||
MessageSquarePlusIcon,
|
||||
} from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import type { ImConversation } from "@/lib/api/im"
|
||||
import { getConversationStatusMessageKey } from "@/lib/support-conversation-i18n"
|
||||
import {
|
||||
isOpenImConversation,
|
||||
resolveConversationTitle,
|
||||
totalUnreadConversations,
|
||||
} from "@/lib/support-conversation-list"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type SupportChatConversationSwitcherProps = {
|
||||
title: string
|
||||
subtitle: string
|
||||
status: string
|
||||
onNewRequest: () => void
|
||||
onCloseActiveRequest?: () => void
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: number) {
|
||||
if (status === 4) {
|
||||
return "bg-muted text-muted-foreground"
|
||||
}
|
||||
if (status === 2) {
|
||||
return "bg-amber-500/12 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300"
|
||||
}
|
||||
return "bg-emerald-500/12 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300"
|
||||
}
|
||||
|
||||
function getStatusDotClass(status: string) {
|
||||
if (status === "connected") {
|
||||
return "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.14)]"
|
||||
}
|
||||
if (status === "connecting") {
|
||||
return "bg-amber-500 shadow-[0_0_0_3px_rgba(245,158,11,0.16)]"
|
||||
}
|
||||
return "bg-muted-foreground shadow-[0_0_0_3px_rgba(148,163,184,0.14)]"
|
||||
}
|
||||
|
||||
export function SupportChatConversationSwitcher({
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
onNewRequest,
|
||||
onCloseActiveRequest,
|
||||
}: SupportChatConversationSwitcherProps) {
|
||||
const t = useI18n()
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
creatingConversation,
|
||||
selectConversation,
|
||||
} = useSupportChatStore(
|
||||
useShallow((state) => ({
|
||||
conversations: state.conversations,
|
||||
activeConversationId: state.activeConversationId,
|
||||
creatingConversation: state.creatingConversation,
|
||||
selectConversation: state.selectConversation,
|
||||
}))
|
||||
)
|
||||
|
||||
const openCount = useMemo(
|
||||
() => conversations.filter((item) => isOpenImConversation(item)).length,
|
||||
[conversations]
|
||||
)
|
||||
const totalUnread = useMemo(
|
||||
() => totalUnreadConversations(conversations),
|
||||
[conversations]
|
||||
)
|
||||
const active = useMemo(
|
||||
() => conversations.find((item) => item.id === activeConversationId) ?? null,
|
||||
[conversations, activeConversationId]
|
||||
)
|
||||
|
||||
const activeSubject = active?.subject?.trim()
|
||||
const hasMultiple = conversations.length > 1
|
||||
|
||||
const displayTitle = activeSubject
|
||||
? activeSubject
|
||||
: hasMultiple && active
|
||||
? resolveConversationTitle(active, t("supportChat.requestFallbackTitle"))
|
||||
: title
|
||||
|
||||
const displaySubtitle = activeSubject || hasMultiple
|
||||
? title
|
||||
: subtitle
|
||||
|
||||
const avatarElement = (
|
||||
<div className="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/15 dark:bg-primary/15 dark:ring-primary/25">
|
||||
<HeadphonesIcon className="size-4" />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-card",
|
||||
getStatusDotClass(status)
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!hasMultiple) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 p-1 -m-1">
|
||||
{avatarElement}
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{displayTitle}
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displaySubtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-md p-1 -m-1 text-left transition-colors hover:bg-foreground/[0.04] group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
||||
aria-label={t("supportChat.myRequests")}
|
||||
>
|
||||
{avatarElement}
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{displayTitle}
|
||||
</span>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100 transition-opacity" />
|
||||
{totalUnread > 0 ? (
|
||||
<span className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground">
|
||||
{totalUnread > 99 ? "99+" : totalUnread}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displaySubtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] p-0">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("supportChat.myRequests")}</span>
|
||||
<span className="shrink-0 font-normal">
|
||||
{t("supportChat.openRequestCount", {
|
||||
open: openCount,
|
||||
total: conversations.length,
|
||||
})}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{conversations.map((conversation: ImConversation) => (
|
||||
<DropdownMenuItem
|
||||
key={conversation.id}
|
||||
onClick={() => void selectConversation(conversation.id)}
|
||||
className="flex-col items-start gap-1 px-3 py-2"
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-primary",
|
||||
conversation.id === activeConversationId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{resolveConversationTitle(
|
||||
conversation,
|
||||
t("supportChat.requestFallbackTitle")
|
||||
)}
|
||||
</span>
|
||||
{Number(conversation.customerUnreadCount) > 0 ? (
|
||||
<span className="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground">
|
||||
{conversation.customerUnreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex w-full items-center gap-1.5 pl-5 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
getStatusBadgeClass(conversation.status)
|
||||
)}
|
||||
>
|
||||
{t(getConversationStatusMessageKey(conversation.status))}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{formatDateTime(conversation.lastActiveAt)}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="flex items-center gap-1.5 p-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={creatingConversation}
|
||||
onClick={onNewRequest}
|
||||
className="h-8 flex-1 justify-start gap-1.5 text-xs"
|
||||
>
|
||||
<MessageSquarePlusIcon className="size-3.5" />
|
||||
<span className="truncate">
|
||||
{creatingConversation
|
||||
? t("supportChat.creatingRequest")
|
||||
: t("supportChat.newRequest")}
|
||||
</span>
|
||||
</Button>
|
||||
{active && active.status !== 4 && onCloseActiveRequest ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCloseActiveRequest}
|
||||
className="h-8 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
|
||||
title={t("supportChat.confirmClose")}
|
||||
>
|
||||
<CheckCircle2Icon className="size-3.5" />
|
||||
<span>{t("supportChat.confirmClose")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
228
web/components/support-chat/csat-card.tsx
Normal file
228
web/components/support-chat/csat-card.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2Icon, StarIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
import {
|
||||
submitConversationSatisfaction,
|
||||
type ConversationSatisfactionResponse,
|
||||
} from "@/lib/api/im"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type CSATCardProps = {
|
||||
conversationId: number
|
||||
customerToken?: string
|
||||
initialSatisfaction?: ConversationSatisfactionResponse | null
|
||||
onSubmitted?: (satisfaction: ConversationSatisfactionResponse) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function getScoreLabel(score: number, t: (key: string) => string): string {
|
||||
switch (score) {
|
||||
case 1:
|
||||
return t("csat.score1")
|
||||
case 2:
|
||||
return t("csat.score2")
|
||||
case 3:
|
||||
return t("csat.score3")
|
||||
case 4:
|
||||
return t("csat.score4")
|
||||
case 5:
|
||||
return t("csat.score5")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function CSATCard({
|
||||
conversationId,
|
||||
customerToken,
|
||||
initialSatisfaction,
|
||||
onSubmitted,
|
||||
className,
|
||||
}: CSATCardProps) {
|
||||
const t = useI18n()
|
||||
const [satisfaction, setSatisfaction] = useState<ConversationSatisfactionResponse | null>(
|
||||
initialSatisfaction ?? null
|
||||
)
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(
|
||||
initialSatisfaction?.score ?? null
|
||||
)
|
||||
const [hoveredScore, setHoveredScore] = useState<number | null>(null)
|
||||
const [feedback, setFeedback] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSatisfaction(initialSatisfaction ?? null)
|
||||
setSelectedScore(initialSatisfaction?.score ?? null)
|
||||
setHoveredScore(null)
|
||||
setFeedback("")
|
||||
setError(null)
|
||||
}, [conversationId, initialSatisfaction])
|
||||
|
||||
if (satisfaction) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2.5 rounded-md border border-border/80 bg-card/90 p-4 text-center text-card-foreground dark:bg-card/70",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
role="img"
|
||||
aria-label={t("csat.alreadyRated", { score: String(satisfaction.score) })}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<StarIcon
|
||||
key={star}
|
||||
className={cn(
|
||||
"size-5",
|
||||
star <= satisfaction.score
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground/25"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">{t("csat.thankYou")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("csat.alreadyRated", { score: String(satisfaction.score) })}
|
||||
</p>
|
||||
</div>
|
||||
{satisfaction.feedback ? (
|
||||
<p className="mt-1 max-w-xs text-xs text-muted-foreground/90 italic break-words">
|
||||
“{satisfaction.feedback}”
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeScore = hoveredScore ?? selectedScore
|
||||
const activeScoreLabel = activeScore ? getScoreLabel(activeScore, t) : ""
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedScore || isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await submitConversationSatisfaction(
|
||||
{
|
||||
conversationId,
|
||||
score: selectedScore,
|
||||
feedback: feedback.trim() || undefined,
|
||||
},
|
||||
customerToken
|
||||
)
|
||||
setSatisfaction(result)
|
||||
onSubmitted?.(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("api.requestFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-md border border-border/80 bg-card/90 p-4 text-card-foreground dark:bg-card/70",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-foreground">{t("csat.title")}</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("csat.title")}
|
||||
className="flex items-center gap-1.5"
|
||||
onMouseLeave={() => setHoveredScore(null)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isFilled = activeScore !== null && star <= activeScore
|
||||
const isSelected = selectedScore === star
|
||||
const starLabel = getScoreLabel(star, t)
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
aria-label={`${star} - ${starLabel}`}
|
||||
disabled={isSubmitting}
|
||||
onMouseEnter={() => setHoveredScore(star)}
|
||||
onFocus={() => setHoveredScore(star)}
|
||||
onBlur={() => setHoveredScore(null)}
|
||||
onClick={() => setSelectedScore(star)}
|
||||
className="group rounded-md p-1 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"size-6 transition-colors",
|
||||
isFilled
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground/35 group-hover:text-amber-400/70"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="h-4 text-xs font-medium text-muted-foreground transition-all"
|
||||
>
|
||||
{activeScoreLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder={t("csat.commentPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
rows={2}
|
||||
className="resize-none text-xs"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" aria-live="polite" className="text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedScore || isSubmitting}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 size-3.5 animate-spin" />
|
||||
{t("csat.submitting")}
|
||||
</>
|
||||
) : (
|
||||
t("csat.submit")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
34
web/components/support-chat/customer-message-editor.tsx
Normal file
34
web/components/support-chat/customer-message-editor.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
SharedMessageEditor,
|
||||
type UploadedMessageEditorImage,
|
||||
} from "@/components/chat/shared-message-editor"
|
||||
|
||||
type CustomerMessageEditorProps = {
|
||||
disabled?: boolean
|
||||
uploadingAsset?: boolean
|
||||
onSend: (html: string) => Promise<void>
|
||||
onUploadImage: (file: File) => Promise<UploadedMessageEditorImage | null>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
}
|
||||
|
||||
export function CustomerMessageEditor({
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
onUploadImage,
|
||||
onSendAttachment,
|
||||
}: CustomerMessageEditorProps) {
|
||||
return (
|
||||
<SharedMessageEditor
|
||||
variant="customer"
|
||||
disabled={disabled}
|
||||
uploadingAsset={uploadingAsset}
|
||||
manageLocalUploading
|
||||
onSend={onSend}
|
||||
onUploadImage={onUploadImage}
|
||||
onSendAttachment={onSendAttachment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
web/components/support-chat/demo-navigation.test.mjs
Normal file
29
web/components/support-chat/demo-navigation.test.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadDemoNavigation() {
|
||||
const source = await readFile(new URL("./demo-navigation.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "demo-navigation.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("widget demo lives below support so /support remains available", async () => {
|
||||
const { getWidgetDemoPath } = await loadDemoNavigation()
|
||||
|
||||
assert.equal(getWidgetDemoPath(), "/support/demo")
|
||||
})
|
||||
5
web/components/support-chat/demo-navigation.ts
Normal file
5
web/components/support-chat/demo-navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_DEMO_PATH = "/support/demo"
|
||||
|
||||
export function getWidgetDemoPath() {
|
||||
return SUPPORT_DEMO_PATH
|
||||
}
|
||||
335
web/components/support-chat/message-list.tsx
Normal file
335
web/components/support-chat/message-list.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react"
|
||||
|
||||
import { AgentActivityBubble } from "@/components/chat/agent-activity-bubble"
|
||||
import { ConversationMessageBubble } from "@/components/chat/conversation-message-bubble"
|
||||
import { ConversationMessageRow } from "@/components/chat/conversation-message-row"
|
||||
import {
|
||||
ConversationMessageScroller,
|
||||
ConversationMessageScrollerItem,
|
||||
type ConversationMessageScrollerHandle,
|
||||
} from "@/components/chat/conversation-message-scroller"
|
||||
import { ImMessageHTML } from "@/components/im-message-html"
|
||||
import { useImageLightbox } from "@/components/image-lightbox"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PromptStarters } from "@/components/support-chat/prompt-starters"
|
||||
import type { AgentActivityInfo } from "@/lib/agent-activity-state"
|
||||
import type { ImMessage } from "@/lib/api/im"
|
||||
import { renderIMMessageHTML } from "@/lib/im-message"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type SupportChatMessageListProps = {
|
||||
messages?: ImMessage[] | null
|
||||
agentActivity?: AgentActivityInfo | null
|
||||
promptStarters?: string[]
|
||||
onSelectPromptStarter?: (starter: string) => void
|
||||
onNearBottomVisible?: () => void
|
||||
hasMoreOlder?: boolean
|
||||
loadingOlder?: boolean
|
||||
onLoadOlder?: () => Promise<void>
|
||||
}
|
||||
|
||||
export type SupportChatMessageListHandle = {
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
function getDayKey(value?: string) {
|
||||
if (!value) {
|
||||
return "unknown"
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
date.getDate()
|
||||
).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function getTimelineLabel(
|
||||
value: string | undefined,
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
if (!value) {
|
||||
return t("supportChat.justNow")
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
const currentDayKey = getDayKey(value)
|
||||
const todayDayKey = getDayKey(new Date().toISOString())
|
||||
const timeText = `${String(date.getHours()).padStart(2, "0")}:${String(
|
||||
date.getMinutes()
|
||||
).padStart(2, "0")}`
|
||||
if (currentDayKey === todayDayKey) {
|
||||
return t("supportChat.todayAt", { time: timeText })
|
||||
}
|
||||
return `${currentDayKey} ${timeText}`
|
||||
}
|
||||
|
||||
export const SupportChatMessageList = forwardRef<SupportChatMessageListHandle, SupportChatMessageListProps>(
|
||||
function SupportChatMessageList(
|
||||
{
|
||||
messages,
|
||||
agentActivity,
|
||||
promptStarters,
|
||||
onSelectPromptStarter,
|
||||
onNearBottomVisible,
|
||||
hasMoreOlder = false,
|
||||
loadingOlder = false,
|
||||
onLoadOlder,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const t = useI18n()
|
||||
const scrollerRef = useRef<ConversationMessageScrollerHandle | null>(null)
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
const onNearBottomVisibleRef = useRef(onNearBottomVisible)
|
||||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
const storeAgentActivity = useSupportChatStore((state) => state.activeAgentActivity)
|
||||
const storePromptStarters = useSupportChatStore((state) => state.promptStarters)
|
||||
const storeHandleSendMessage = useSupportChatStore((state) => state.handleSendMessage)
|
||||
const storeSending = useSupportChatStore((state) => state.sending)
|
||||
|
||||
const resolvedAgentActivity = agentActivity !== undefined ? agentActivity : storeAgentActivity
|
||||
const resolvedPromptStarters = promptStarters !== undefined ? promptStarters : storePromptStarters
|
||||
const handleSelectPromptStarter = onSelectPromptStarter ?? storeHandleSendMessage
|
||||
useEffect(() => {
|
||||
onNearBottomVisibleRef.current = onNearBottomVisible
|
||||
}, [onNearBottomVisible])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollerRef.current?.scrollToBottom()
|
||||
}, [])
|
||||
|
||||
const prevWasActiveRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const wasActive = prevWasActiveRef.current
|
||||
const isNowActive = Boolean(resolvedAgentActivity)
|
||||
prevWasActiveRef.current = isNowActive
|
||||
|
||||
if (!wasActive && isNowActive && shouldStickToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [resolvedAgentActivity, scrollToBottom])
|
||||
|
||||
const handleImageSettled = useCallback(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom()
|
||||
onNearBottomVisibleRef.current?.()
|
||||
}
|
||||
}, [scrollToBottom])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom,
|
||||
}))
|
||||
|
||||
const handleLoadOlder = useCallback(async () => {
|
||||
if (!onLoadOlder || loadingOlder || !hasMoreOlder) {
|
||||
return
|
||||
}
|
||||
await onLoadOlder()
|
||||
}, [hasMoreOlder, loadingOlder, onLoadOlder])
|
||||
|
||||
return (
|
||||
<ConversationMessageScroller
|
||||
ref={scrollerRef}
|
||||
className="flex min-h-0 flex-1"
|
||||
viewportClassName="bg-transparent"
|
||||
contentClassName="gap-4 px-4 py-4"
|
||||
hasMoreOlder={hasMoreOlder}
|
||||
loadingOlder={loadingOlder}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onNearBottomChange={(nearBottom) => {
|
||||
shouldStickToBottomRef.current = nearBottom
|
||||
}}
|
||||
onNearBottomVisible={onNearBottomVisible}
|
||||
topSlot={
|
||||
hasMoreOlder && onLoadOlder ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loadingOlder}
|
||||
onClick={() => void handleLoadOlder()}
|
||||
className="h-7 rounded-full bg-background/90 text-xs text-muted-foreground shadow-sm hover:bg-background hover:text-sky-700 dark:hover:text-sky-400"
|
||||
>
|
||||
{loadingOlder ? t("supportChat.loadingOlder") : t("supportChat.loadOlder")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{safeMessages.length === 0 && !resolvedAgentActivity ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center gap-4 px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
<p className="max-w-xs">{t("supportChat.emptyPrompt")}</p>
|
||||
{resolvedPromptStarters && resolvedPromptStarters.length > 0 ? (
|
||||
<PromptStarters
|
||||
items={resolvedPromptStarters}
|
||||
disabled={storeSending}
|
||||
onSelect={(starter) => {
|
||||
void handleSelectPromptStarter?.(starter)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{safeMessages.map((message, index) => {
|
||||
const previousMessage = index > 0 ? safeMessages[index - 1] : null
|
||||
const showTimeline =
|
||||
index === 0 ||
|
||||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
|
||||
|
||||
return (
|
||||
<ConversationMessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={`${message.id}`}
|
||||
>
|
||||
<MessageItem
|
||||
message={message}
|
||||
showTimeline={showTimeline}
|
||||
onImageSettled={handleImageSettled}
|
||||
timelineLabel={getTimelineLabel(message.sentAt, t)}
|
||||
/>
|
||||
</ConversationMessageScrollerItem>
|
||||
)
|
||||
})}
|
||||
|
||||
{resolvedAgentActivity ? (
|
||||
<AgentActivityBubble
|
||||
stepCode={resolvedAgentActivity.stepCode}
|
||||
toolName={resolvedAgentActivity.toolName}
|
||||
isCustomer
|
||||
/>
|
||||
) : null}
|
||||
</ConversationMessageScroller>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type MessageItemProps = {
|
||||
message: ImMessage
|
||||
showTimeline: boolean
|
||||
onImageSettled: () => void
|
||||
timelineLabel: string
|
||||
}
|
||||
|
||||
const MessageItem = memo(
|
||||
function MessageItem({ message, showTimeline, onImageSettled, timelineLabel }: MessageItemProps) {
|
||||
const t = useI18n()
|
||||
const { open } = useImageLightbox()
|
||||
const isCustomer = message.senderType === "customer"
|
||||
// The server substitutes recalled messages with its own placeholder text, so
|
||||
// rendering content as-is would show a visitor that placeholder in the
|
||||
// server's language. The dashboard already replaces it; the portal did not.
|
||||
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6
|
||||
const senderName = isCustomer ? t("supportChat.customerSelf") : message.senderName?.trim() || t("supportChat.agentLabel")
|
||||
const avatarSrc =
|
||||
!isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
|
||||
const htmlContent = isRecalled
|
||||
? `<p>${t("conversation.messageRecalledHtml")}</p>`
|
||||
: renderIMMessageHTML(message)
|
||||
const fallbackName = senderName.slice(0, 1).toUpperCase()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showTimeline ? (
|
||||
<div className="mb-3 flex items-center justify-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-border bg-background/85 text-[11px] font-medium text-muted-foreground shadow-sm"
|
||||
>
|
||||
{timelineLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConversationMessageRow
|
||||
align={isCustomer ? "end" : "start"}
|
||||
avatar={
|
||||
!isCustomer ? (
|
||||
<Avatar>
|
||||
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
|
||||
<AvatarFallback className="bg-muted text-muted-foreground">
|
||||
{fallbackName || t("supportChat.customerFallback")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null
|
||||
}
|
||||
contentClassName="max-w-[86%] gap-1.5"
|
||||
headerClassName="flex-wrap gap-x-2 gap-y-1 px-1 text-[11px]"
|
||||
header={
|
||||
<>
|
||||
<span className="font-medium">{senderName}</span>
|
||||
<span>{formatDateTime(message.sentAt)}</span>
|
||||
{isCustomer ? (
|
||||
<span>{message.agentRead ? t("supportChat.agentRead") : t("supportChat.agentUnread")}</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConversationMessageBubble
|
||||
variant={isRecalled ? "recalled" : isCustomer ? "customer" : "system"}
|
||||
className={cn(
|
||||
"rounded-lg border-0 px-3 py-2 text-sm leading-normal shadow-[0_10px_22px_rgba(15,23,42,0.06)]",
|
||||
isRecalled
|
||||
? "!border-border !bg-muted !text-muted-foreground"
|
||||
: isCustomer
|
||||
? "!bg-[#a9ea7a] !text-[#161616] dark:!bg-emerald-500 dark:!text-emerald-950"
|
||||
: "!border-border !bg-card !text-card-foreground dark:!bg-background"
|
||||
)}
|
||||
>
|
||||
<ImMessageHTML
|
||||
html={htmlContent}
|
||||
className={cn(
|
||||
isRecalled
|
||||
? "[&_p]:text-muted-foreground"
|
||||
: isCustomer
|
||||
? "[&_p]:text-[#161616] dark:[&_p]:text-emerald-950 [&_a]:text-[#161616] dark:[&_a]:text-emerald-950 [&_a]:underline [&_img]:cursor-zoom-in"
|
||||
: "[&_a]:text-card-foreground [&_a]:underline [&_img]:cursor-zoom-in"
|
||||
)}
|
||||
onImageSettled={onImageSettled}
|
||||
onImageClick={isRecalled ? undefined : open}
|
||||
/>
|
||||
</ConversationMessageBubble>
|
||||
</ConversationMessageRow>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
isSameMessageItemRender(prevProps.message, nextProps.message) &&
|
||||
prevProps.showTimeline === nextProps.showTimeline &&
|
||||
prevProps.timelineLabel === nextProps.timelineLabel &&
|
||||
prevProps.onImageSettled === nextProps.onImageSettled
|
||||
)
|
||||
|
||||
function isSameMessageItemRender(prev: ImMessage, next: ImMessage) {
|
||||
return (
|
||||
prev.id === next.id &&
|
||||
prev.senderType === next.senderType &&
|
||||
prev.senderName === next.senderName &&
|
||||
prev.senderAvatar === next.senderAvatar &&
|
||||
prev.messageType === next.messageType &&
|
||||
prev.content === next.content &&
|
||||
prev.payload === next.payload &&
|
||||
prev.sentAt === next.sentAt &&
|
||||
prev.agentRead === next.agentRead
|
||||
)
|
||||
}
|
||||
171
web/components/support-chat/new-request-dialog.tsx
Normal file
171
web/components/support-chat/new-request-dialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
const SUBJECT_MAX_LENGTH = 255
|
||||
|
||||
type SupportChatNewRequestDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for an optional subject before starting a request. The subject is what
|
||||
* tells several requests apart in the switcher, so it is worth prompting for -
|
||||
* but a visitor in a hurry must not be blocked, hence "optional" and an
|
||||
* immediate path through.
|
||||
*/
|
||||
export function SupportChatNewRequestDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: SupportChatNewRequestDialogProps) {
|
||||
const t = useI18n()
|
||||
const creatingConversation = useSupportChatStore(
|
||||
(state) => state.creatingConversation
|
||||
)
|
||||
const startNewConversation = useSupportChatStore(
|
||||
(state) => state.startNewConversation
|
||||
)
|
||||
const [subject, setSubject] = useState("")
|
||||
const [validationError, setValidationError] = useState("")
|
||||
const [submitFailed, setSubmitFailed] = useState(false)
|
||||
|
||||
function resetForm() {
|
||||
setSubject("")
|
||||
setValidationError("")
|
||||
setSubmitFailed(false)
|
||||
}
|
||||
|
||||
// The dialog body unmounts whenever it is closed, so resetting on close rather
|
||||
// than in an effect keyed on `open` also avoids a cascading render.
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (creatingConversation) {
|
||||
return
|
||||
}
|
||||
if (!nextOpen) {
|
||||
resetForm()
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = subject.trim()
|
||||
if ([...trimmed].length > SUBJECT_MAX_LENGTH) {
|
||||
setValidationError(t("supportChat.newRequestSubjectTooLong"))
|
||||
return
|
||||
}
|
||||
|
||||
setValidationError("")
|
||||
setSubmitFailed(false)
|
||||
try {
|
||||
await startNewConversation(trimmed)
|
||||
onCreated?.()
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// The store already surfaced the localized reason on its error strip;
|
||||
// keeping the dialog open preserves what the visitor typed.
|
||||
setSubmitFailed(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[380px]"
|
||||
showCloseButton={!creatingConversation}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("supportChat.newRequestDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("supportChat.newRequestDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="support-chat-new-request-subject">
|
||||
{t("supportChat.newRequestSubjectLabel")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="support-chat-new-request-subject"
|
||||
value={subject}
|
||||
maxLength={SUBJECT_MAX_LENGTH}
|
||||
placeholder={t("supportChat.newRequestSubjectPlaceholder")}
|
||||
disabled={creatingConversation}
|
||||
aria-invalid={validationError ? true : undefined}
|
||||
aria-describedby={
|
||||
validationError
|
||||
? "support-chat-new-request-subject-error"
|
||||
: "support-chat-new-request-subject-hint"
|
||||
}
|
||||
onChange={(event) => {
|
||||
setSubject(event.target.value)
|
||||
if (validationError) {
|
||||
setValidationError("")
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !creatingConversation) {
|
||||
event.preventDefault()
|
||||
void handleCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{validationError ? (
|
||||
<p
|
||||
id="support-chat-new-request-subject-error"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{validationError}
|
||||
</p>
|
||||
) : (
|
||||
<FieldDescription id="support-chat-new-request-subject-hint">
|
||||
{t("supportChat.newRequestSubjectHint")}
|
||||
</FieldDescription>
|
||||
)}
|
||||
{submitFailed ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("supportChat.createConversationFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={creatingConversation}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={creatingConversation}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creatingConversation
|
||||
? t("supportChat.creatingRequest")
|
||||
: t("supportChat.createRequest")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
51
web/components/support-chat/prompt-starters.tsx
Normal file
51
web/components/support-chat/prompt-starters.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { ArrowRightIcon, CircleHelpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
export type PromptStartersProps = {
|
||||
items?: string[] | null
|
||||
onSelect?: (text: string) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PromptStarters({
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
className,
|
||||
}: PromptStartersProps) {
|
||||
const t = useI18n()
|
||||
const safeItems = Array.isArray(items)
|
||||
? items.map((item) => item.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (safeItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("promptStarters.title")}
|
||||
className={cn("flex w-full max-w-md flex-col gap-2", className)}
|
||||
>
|
||||
{safeItems.map((item, index) => (
|
||||
<button
|
||||
key={`${index}-${item}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect?.(item)}
|
||||
className="group flex w-full items-center gap-2.5 rounded-lg border border-border/80 bg-card/80 px-3.5 py-2.5 text-left text-sm text-card-foreground shadow-xs transition-all hover:border-primary/40 hover:bg-accent/60 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99] disabled:pointer-events-none disabled:opacity-50 dark:bg-card/50"
|
||||
>
|
||||
<CircleHelpIcon className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
|
||||
<span className="flex-1 font-normal leading-snug break-words">{item}</span>
|
||||
<ArrowRightIcon className="size-3.5 shrink-0 text-muted-foreground/60 opacity-0 transition-all group-hover:opacity-100 group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
466
web/components/support-chat/widget-demo.tsx
Normal file
466
web/components/support-chat/widget-demo.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
"use client"
|
||||
|
||||
import { SignJWT } from "jose"
|
||||
import { CheckIcon, CopyIcon } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import type { AgentDeskConfig } from "@/lib/sdk/config-types"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
const STORAGE_KEY = "agent-desk-web-widget-test-config"
|
||||
const DEFAULT_JWT_TTL_MINUTES = "30"
|
||||
const INITIAL_CONFIG: AgentDeskConfig = {
|
||||
channelId: "",
|
||||
baseUrl: "",
|
||||
apiBaseUrl: "",
|
||||
}
|
||||
|
||||
type AuthMode = "guest" | "jwt"
|
||||
|
||||
type WidgetDemoConfig = AgentDeskConfig & {
|
||||
authMode?: AuthMode
|
||||
jwtSecret?: string
|
||||
jwtUserId?: string
|
||||
jwtName?: string
|
||||
jwtTtlMinutes?: string
|
||||
}
|
||||
|
||||
function getDefaultConfig(defaultName: string): WidgetDemoConfig {
|
||||
if (typeof window === "undefined") {
|
||||
return INITIAL_CONFIG
|
||||
}
|
||||
|
||||
const savedText = window.localStorage.getItem(STORAGE_KEY)
|
||||
const savedConfig = savedText
|
||||
? (JSON.parse(savedText) as Partial<WidgetDemoConfig>)
|
||||
: {}
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
|
||||
return {
|
||||
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
|
||||
baseUrl: "",
|
||||
apiBaseUrl: "",
|
||||
authMode: (query.get("authMode") as AuthMode | null) ?? savedConfig.authMode ?? "guest",
|
||||
jwtSecret: savedConfig.jwtSecret ?? "",
|
||||
jwtUserId: query.get("userId") ?? savedConfig.jwtUserId ?? "demo-user-001",
|
||||
jwtName: query.get("name") ?? savedConfig.jwtName ?? defaultName,
|
||||
jwtTtlMinutes: savedConfig.jwtTtlMinutes ?? DEFAULT_JWT_TTL_MINUTES,
|
||||
}
|
||||
}
|
||||
|
||||
function removeMountedWidget() {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
window.AgentDeskWidget?.destroy()
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[data-agent-desk-widget="launcher"], [data-agent-desk-widget="frame"], [data-agent-desk-widget="script"]'
|
||||
)
|
||||
.forEach((node) => node.remove())
|
||||
|
||||
delete window.AgentDeskConfig
|
||||
delete window.__CS_AI_AGENT_WIDGET_CONFIG__
|
||||
delete window.__CS_AI_AGENT_WIDGET_STATE__
|
||||
delete window.AgentDeskWidget
|
||||
}
|
||||
|
||||
function injectWidget(config: AgentDeskConfig) {
|
||||
removeMountedWidget()
|
||||
window.AgentDeskConfig = config
|
||||
|
||||
const script = document.createElement("script")
|
||||
script.async = true
|
||||
script.src = `${window.location.origin}/sdk/agent-desk-sdk.min.js`
|
||||
script.dataset.agentDeskWidget = "script"
|
||||
document.body.appendChild(script)
|
||||
}
|
||||
|
||||
function buildWidgetConfig(config: WidgetDemoConfig): AgentDeskConfig {
|
||||
const nextConfig: AgentDeskConfig = {
|
||||
channelId: config.channelId.trim(),
|
||||
baseUrl: "",
|
||||
apiBaseUrl: "",
|
||||
}
|
||||
if (config.authMode === "jwt") {
|
||||
nextConfig.getUserToken = undefined
|
||||
}
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
async function signUserToken(config: WidgetDemoConfig, t: (key: string) => string) {
|
||||
const userId = (config.jwtUserId || "").trim()
|
||||
const name = (config.jwtName || "").trim()
|
||||
const secret = (config.jwtSecret || "").trim()
|
||||
const ttl = Number(config.jwtTtlMinutes || DEFAULT_JWT_TTL_MINUTES)
|
||||
|
||||
if (!userId) {
|
||||
throw new Error(t("widgetDemo.missingUserId"))
|
||||
}
|
||||
if (!name) {
|
||||
throw new Error(t("widgetDemo.missingName"))
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(t("widgetDemo.missingSecret"))
|
||||
}
|
||||
if (!Number.isFinite(ttl) || ttl <= 0) {
|
||||
throw new Error(t("widgetDemo.invalidTtl"))
|
||||
}
|
||||
|
||||
return new SignJWT({ userId, name })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${ttl}m`)
|
||||
.sign(new TextEncoder().encode(secret))
|
||||
}
|
||||
|
||||
export function SupportWidgetDemo() {
|
||||
const t = useI18n()
|
||||
const [config, setConfig] = useState<WidgetDemoConfig>({
|
||||
...INITIAL_CONFIG,
|
||||
authMode: "guest",
|
||||
jwtSecret: "",
|
||||
jwtUserId: "demo-user-001",
|
||||
jwtName: t("widgetDemo.defaultName"),
|
||||
jwtTtlMinutes: DEFAULT_JWT_TTL_MINUTES,
|
||||
})
|
||||
const [status, setStatus] = useState(t("widgetDemo.missingChannel"))
|
||||
const [origin, setOrigin] = useState("")
|
||||
const [generatedToken, setGeneratedToken] = useState("")
|
||||
const [latestDirectChatUrl, setLatestDirectChatUrl] = useState("")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||
|
||||
async function mountWidget(configToMount: WidgetDemoConfig) {
|
||||
const cleanConfig = {
|
||||
...configToMount,
|
||||
channelId: configToMount.channelId.trim(),
|
||||
baseUrl: "",
|
||||
apiBaseUrl: "",
|
||||
getUserToken: undefined,
|
||||
}
|
||||
const nextConfig = buildWidgetConfig(cleanConfig)
|
||||
if (cleanConfig.authMode === "jwt") {
|
||||
nextConfig.getUserToken = () => signUserToken(cleanConfig, t)
|
||||
}
|
||||
setConfig(cleanConfig)
|
||||
setGeneratedToken("")
|
||||
setLatestDirectChatUrl("")
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanConfig))
|
||||
|
||||
if (!nextConfig.channelId) {
|
||||
removeMountedWidget()
|
||||
setStatus(t("widgetDemo.missingChannel"))
|
||||
return
|
||||
}
|
||||
|
||||
injectWidget(nextConfig)
|
||||
setStatus(
|
||||
cleanConfig.authMode === "jwt"
|
||||
? t("widgetDemo.mountedJwt")
|
||||
: t("widgetDemo.mountedGuest")
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const initialConfig = getDefaultConfig(t("widgetDemo.defaultName"))
|
||||
setOrigin(window.location.origin)
|
||||
setConfig(initialConfig)
|
||||
setStatus(initialConfig.channelId ? t("widgetDemo.mounted") : t("widgetDemo.missingChannel"))
|
||||
|
||||
if (initialConfig.channelId) {
|
||||
void mountWidget(initialConfig).catch((error) => {
|
||||
removeMountedWidget()
|
||||
setGeneratedToken("")
|
||||
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
removeMountedWidget()
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const scriptSrc = origin
|
||||
? `${origin}/sdk/agent-desk-sdk.min.js`
|
||||
: "/sdk/agent-desk-sdk.min.js"
|
||||
|
||||
const configLines = [` channelId: "${config.channelId || ""}"`]
|
||||
if (config.authMode === "jwt") {
|
||||
configLines.push(` async getUserToken() {
|
||||
const res = await fetch("/api/support/user-token", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data.userToken;
|
||||
}`)
|
||||
}
|
||||
|
||||
return `<script>
|
||||
window.AgentDeskConfig = {
|
||||
${configLines.join(",\n")}
|
||||
};
|
||||
</script>
|
||||
<script async src="${scriptSrc}"></script>`
|
||||
}, [config.authMode, config.channelId, origin])
|
||||
|
||||
function updateField<K extends keyof WidgetDemoConfig>(
|
||||
key: K,
|
||||
value: WidgetDemoConfig[K]
|
||||
) {
|
||||
setConfig((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
async function handleMount() {
|
||||
try {
|
||||
await mountWidget(config)
|
||||
} catch (error) {
|
||||
removeMountedWidget()
|
||||
setGeneratedToken("")
|
||||
setStatus(error instanceof Error ? error.message : t("widgetDemo.mountFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyDirectUrl() {
|
||||
if (!window.AgentDeskWidget || typeof navigator === "undefined") {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = await window.AgentDeskWidget.getChatUrl()
|
||||
setLatestDirectChatUrl(url)
|
||||
if (config.authMode === "jwt") {
|
||||
setGeneratedToken(new URL(url).searchParams.get("userToken") || "")
|
||||
}
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1600)
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenDirectChat() {
|
||||
if (!window.AgentDeskWidget) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = await window.AgentDeskWidget.getChatUrl()
|
||||
setLatestDirectChatUrl(url)
|
||||
if (config.authMode === "jwt") {
|
||||
setGeneratedToken(new URL(url).searchParams.get("userToken") || "")
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : t("widgetDemo.linkFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopySnippet() {
|
||||
if (typeof navigator === "undefined") {
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(snippet)
|
||||
setSnippetCopied(true)
|
||||
window.setTimeout(() => setSnippetCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
|
||||
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="text-base font-semibold">{t("widgetDemo.title")}</div>
|
||||
<div className="mt-1 text-sm text-slate-500">{status}</div>
|
||||
|
||||
<div className="mt-5 grid gap-3">
|
||||
<TextField
|
||||
label="channelId"
|
||||
value={config.channelId}
|
||||
onChange={(value) => updateField("channelId", value)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label={t("widgetDemo.authMode")}
|
||||
value={config.authMode || "guest"}
|
||||
onChange={(value) => updateField("authMode", value)}
|
||||
options={[
|
||||
{ label: t("widgetDemo.guest"), value: "guest" },
|
||||
{ label: t("widgetDemo.jwtUser"), value: "jwt" },
|
||||
]}
|
||||
/>
|
||||
{config.authMode === "jwt" ? (
|
||||
<div className="grid gap-3 rounded-md border border-slate-200 p-3">
|
||||
<TextField
|
||||
label="userId"
|
||||
value={config.jwtUserId}
|
||||
onChange={(value) => updateField("jwtUserId", value)}
|
||||
/>
|
||||
<TextField
|
||||
label="name"
|
||||
value={config.jwtName}
|
||||
onChange={(value) => updateField("jwtName", value)}
|
||||
/>
|
||||
<TextField
|
||||
label="JWT Secret"
|
||||
value={config.jwtSecret}
|
||||
onChange={(value) => updateField("jwtSecret", value)}
|
||||
type="password"
|
||||
/>
|
||||
<TextField
|
||||
label={t("widgetDemo.ttlMinutes")}
|
||||
value={config.jwtTtlMinutes}
|
||||
onChange={(value) => updateField("jwtTtlMinutes", value)}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleMount()}
|
||||
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
{t("widgetDemo.mount")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
removeMountedWidget()
|
||||
setStatus(t("widgetDemo.unmounted"))
|
||||
}}
|
||||
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t("widgetDemo.unmount")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="text-base font-semibold">{t("widgetDemo.snippetTitle")}</div>
|
||||
{config.authMode === "jwt" ? (
|
||||
<div className="mt-2 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{t("widgetDemo.jwtNotice")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopySnippet()}
|
||||
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-md border border-white/10 bg-white/10 text-slate-200 transition hover:bg-white/20 hover:text-white"
|
||||
aria-label={snippetCopied ? t("widgetDemo.copiedSnippet") : t("widgetDemo.copySnippet")}
|
||||
title={snippetCopied ? t("widgetDemo.copied") : t("widgetDemo.copyCode")}
|
||||
>
|
||||
{snippetCopied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<pre className="overflow-x-auto rounded-md bg-slate-950 p-4 pr-12 text-xs leading-5 text-slate-100">
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.directChat")}</div>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
readOnly
|
||||
value={latestDirectChatUrl || t("widgetDemo.directChatPlaceholder")}
|
||||
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 px-3 font-mono text-xs outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!config.channelId}
|
||||
onClick={() => void handleCopyDirectUrl()}
|
||||
className="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{copied ? t("widgetDemo.copied") : t("widgetDemo.copy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!config.channelId}
|
||||
onClick={() => void handleOpenDirectChat()}
|
||||
className="rounded-md bg-slate-950 px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t("widgetDemo.openNewWindow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{generatedToken ? (
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium text-slate-700">{t("widgetDemo.currentToken")}</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={generatedToken}
|
||||
className="mt-2 h-28 w-full resize-none rounded-md border border-slate-200 p-3 font-mono text-xs outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="grid gap-1.5 text-sm">
|
||||
<span className="font-medium text-slate-700">{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: Array<{ label: string; value: T }>
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5 text-sm">
|
||||
<div className="font-medium text-slate-700">{label}</div>
|
||||
<div className="grid grid-cols-2 rounded-md border border-slate-200 bg-slate-100 p-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={
|
||||
option.value === value
|
||||
? "rounded bg-white px-3 py-1.5 text-sm font-medium shadow-sm"
|
||||
: "rounded px-3 py-1.5 text-sm text-slate-600"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user