chore: import clean Agent Desk snapshot (6/7)

This commit is contained in:
2026-10-16 12:02:48 +00:00
parent d215bf6303
commit 756ae33d05
278 changed files with 53808 additions and 0 deletions

View 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
}
}

View 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>
)
}

View 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")
})

View File

@@ -0,0 +1,5 @@
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return SUPPORT_CHAT_CLOSED_PATH
}

View 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>
)
}

View 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>/
)
})
})

View 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>
)
}

View 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">
&ldquo;{satisfaction.feedback}&rdquo;
</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>
)
}

View 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}
/>
)
}

View 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")
})

View File

@@ -0,0 +1,5 @@
export const SUPPORT_DEMO_PATH = "/support/demo"
export function getWidgetDemoPath() {
return SUPPORT_DEMO_PATH
}

View 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
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}