feat: add support chat widget demo and state management
- Implemented SupportWidgetDemo component for configuring and mounting the support chat widget. - Introduced Zustand store for managing support chat state, including message handling and socket connection. - Created support host bridge for communication between the widget and parent window. - Added utility functions for JWT token generation and local storage management. - Enhanced user experience with notifications for new messages and chat status updates.
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
export const KEFU_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return KEFU_CLOSED_PATH
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const KEFU_DEMO_PATH = "/support/demo"
|
||||
|
||||
export function getWidgetDemoPath() {
|
||||
return KEFU_DEMO_PATH
|
||||
}
|
||||
@@ -20,20 +20,20 @@ import {
|
||||
} from "react"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { KefuConnectionStatus } from "@/components/kefu/connection-status"
|
||||
import { getStandaloneClosedUrl } from "@/components/kefu/close-navigation"
|
||||
import { CustomerMessageEditor } from "@/components/kefu/customer-message-editor"
|
||||
import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
|
||||
import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
|
||||
import { CustomerMessageEditor } from "@/components/support-chat/customer-message-editor"
|
||||
import {
|
||||
KefuMessageList,
|
||||
type KefuMessageListHandle,
|
||||
} from "@/components/kefu/message-list"
|
||||
SupportChatMessageList,
|
||||
type SupportChatMessageListHandle,
|
||||
} from "@/components/support-chat/message-list"
|
||||
import {
|
||||
bindKefuHostBridge,
|
||||
requestKefuHostClose,
|
||||
requestKefuHostMinimize,
|
||||
requestKefuHostToggleMaximize,
|
||||
} from "@/lib/kefu-host-bridge"
|
||||
import { useKefuChatStore } from "@/lib/stores/kefu-chat"
|
||||
bindSupportHostBridge,
|
||||
requestSupportHostClose,
|
||||
requestSupportHostMinimize,
|
||||
requestSupportHostToggleMaximize,
|
||||
} from "@/lib/support-host-bridge"
|
||||
import { useSupportChatStore } from "@/lib/stores/support-chat"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -80,7 +80,7 @@ function getMobileStatusDotClass(status: string) {
|
||||
return "bg-muted-foreground shadow-[0_0_0_3px_rgba(148,163,184,0.14)]"
|
||||
}
|
||||
|
||||
function useKefuSystemTheme() {
|
||||
function useSupportChatSystemTheme() {
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
@@ -119,11 +119,11 @@ function isEmbeddedInHost() {
|
||||
}
|
||||
}
|
||||
|
||||
export function KefuChatShell() {
|
||||
export function SupportChatShell() {
|
||||
const t = useI18n()
|
||||
useKefuSystemTheme()
|
||||
useSupportChatSystemTheme()
|
||||
|
||||
const messageListRef = useRef<KefuMessageListHandle | null>(null)
|
||||
const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
|
||||
const [isEmbedded, setIsEmbedded] = useState(false)
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
|
||||
@@ -152,7 +152,7 @@ export function KefuChatShell() {
|
||||
disconnectSocket,
|
||||
markConversationRead,
|
||||
closeConversation,
|
||||
} = useKefuChatStore(
|
||||
} = useSupportChatStore(
|
||||
useShallow((state) => ({
|
||||
title: state.title,
|
||||
subtitle: state.subtitle,
|
||||
@@ -192,12 +192,12 @@ export function KefuChatShell() {
|
||||
return
|
||||
}
|
||||
void markConversationRead().catch((readError) => {
|
||||
console.error("Failed to mark kefu conversation read", readError)
|
||||
console.error("Failed to mark support chat conversation read", readError)
|
||||
})
|
||||
}, [conversation?.id, isVisible, markConversationRead])
|
||||
|
||||
useEffect(() => {
|
||||
return bindKefuHostBridge({
|
||||
return bindSupportHostBridge({
|
||||
onOpen: () => {
|
||||
setIsOpen(true)
|
||||
setIsVisible(true)
|
||||
@@ -250,11 +250,11 @@ export function KefuChatShell() {
|
||||
|
||||
function handleMinimize() {
|
||||
setIsVisible(false)
|
||||
requestKefuHostMinimize()
|
||||
requestSupportHostMinimize()
|
||||
}
|
||||
|
||||
function handleToggleMaximize() {
|
||||
requestKefuHostToggleMaximize()
|
||||
requestSupportHostToggleMaximize()
|
||||
}
|
||||
|
||||
async function confirmCloseConversation() {
|
||||
@@ -268,12 +268,12 @@ export function KefuChatShell() {
|
||||
}
|
||||
setIsCloseDialogOpen(false)
|
||||
if (isEmbedded) {
|
||||
requestKefuHostClose()
|
||||
requestSupportHostClose()
|
||||
} else {
|
||||
window.location.replace(getStandaloneClosedUrl())
|
||||
}
|
||||
} catch (closeError) {
|
||||
window.alert(closeError instanceof Error ? closeError.message : t("kefu.closeConversationFailed"))
|
||||
window.alert(closeError instanceof Error ? closeError.message : t("supportChat.closeConversationFailed"))
|
||||
} finally {
|
||||
setIsClosingConversation(false)
|
||||
}
|
||||
@@ -326,8 +326,8 @@ export function KefuChatShell() {
|
||||
{!isEmbedded && status !== "connected" ? (
|
||||
<WindowActionButton
|
||||
onClick={retry}
|
||||
aria-label={t("kefu.retry")}
|
||||
title={t("kefu.retry")}
|
||||
aria-label={t("supportChat.retry")}
|
||||
title={t("supportChat.retry")}
|
||||
>
|
||||
<RotateCwIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
@@ -335,18 +335,18 @@ export function KefuChatShell() {
|
||||
{isEmbedded ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<WindowActionButton aria-label={t("kefu.moreActions")} title={t("kefu.moreActions")} />}
|
||||
render={<WindowActionButton aria-label={t("supportChat.moreActions")} title={t("supportChat.moreActions")} />}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={retry}>
|
||||
<RotateCwIcon className="size-4" />
|
||||
{t("kefu.retry")}
|
||||
{t("supportChat.retry")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleMinimize}>
|
||||
<MinusIcon className="size-4" />
|
||||
{t("kefu.minimize")}
|
||||
{t("supportChat.minimize")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleToggleMaximize}>
|
||||
{isMaximized ? (
|
||||
@@ -354,22 +354,22 @@ export function KefuChatShell() {
|
||||
) : (
|
||||
<Maximize2Icon className="size-4" />
|
||||
)}
|
||||
{isMaximized ? t("kefu.restoreWindow") : t("kefu.maximize")}
|
||||
{isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximize")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
{t("kefu.closeWindow")}
|
||||
{t("supportChat.closeWindow")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<WindowActionButton
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
aria-label={t("kefu.closeChatWindow")}
|
||||
title={t("kefu.closeChatWindow")}
|
||||
aria-label={t("supportChat.closeChatWindow")}
|
||||
title={t("supportChat.closeChatWindow")}
|
||||
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
@@ -378,13 +378,13 @@ export function KefuChatShell() {
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-1 sm:flex sm:gap-2">
|
||||
{status !== "connected" ? (
|
||||
<KefuConnectionStatus status={status} />
|
||||
<SupportChatConnectionStatus status={status} />
|
||||
) : null}
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-background/55 p-0.5 shadow-sm ring-1 ring-border/70 dark:bg-background/25 dark:ring-white/10">
|
||||
<WindowActionButton
|
||||
onClick={retry}
|
||||
aria-label={t("kefu.retry")}
|
||||
title={t("kefu.retry")}
|
||||
aria-label={t("supportChat.retry")}
|
||||
title={t("supportChat.retry")}
|
||||
>
|
||||
<RotateCwIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
@@ -392,15 +392,15 @@ export function KefuChatShell() {
|
||||
<>
|
||||
<WindowActionButton
|
||||
onClick={handleMinimize}
|
||||
aria-label={t("kefu.minimize")}
|
||||
title={t("kefu.minimize")}
|
||||
aria-label={t("supportChat.minimize")}
|
||||
title={t("supportChat.minimize")}
|
||||
>
|
||||
<MinusIcon className="size-4" />
|
||||
</WindowActionButton>
|
||||
<WindowActionButton
|
||||
onClick={handleToggleMaximize}
|
||||
aria-label={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")}
|
||||
title={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")}
|
||||
aria-label={isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximizeWindow")}
|
||||
title={isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximizeWindow")}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2Icon className="size-4" />
|
||||
@@ -412,8 +412,8 @@ export function KefuChatShell() {
|
||||
) : null}
|
||||
<WindowActionButton
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
aria-label={t("kefu.closeChatWindow")}
|
||||
title={t("kefu.closeChatWindow")}
|
||||
aria-label={t("supportChat.closeChatWindow")}
|
||||
title={t("supportChat.closeChatWindow")}
|
||||
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
@@ -424,7 +424,7 @@ export function KefuChatShell() {
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
|
||||
<KefuMessageList
|
||||
<SupportChatMessageList
|
||||
ref={messageListRef}
|
||||
messages={safeMessages}
|
||||
onNearBottomVisible={maybeMarkConversationRead}
|
||||
@@ -459,9 +459,9 @@ export function KefuChatShell() {
|
||||
>
|
||||
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("kefu.closeDialogTitle")}</DialogTitle>
|
||||
<DialogTitle>{t("supportChat.closeDialogTitle")}</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-5">
|
||||
{t("kefu.closeDialogDescription")}
|
||||
{t("supportChat.closeDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -471,7 +471,7 @@ export function KefuChatShell() {
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => setIsCloseDialogOpen(false)}
|
||||
>
|
||||
{t("kefu.continueConversation")}
|
||||
{t("supportChat.continueConversation")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -479,7 +479,7 @@ export function KefuChatShell() {
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => void confirmCloseConversation()}
|
||||
>
|
||||
{isClosingConversation ? t("kefu.closing") : t("kefu.confirmClose")}
|
||||
{isClosingConversation ? t("supportChat.closing") : t("supportChat.confirmClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
|
||||
|
||||
export function getStandaloneClosedUrl() {
|
||||
return SUPPORT_CHAT_CLOSED_PATH
|
||||
}
|
||||
+3
-3
@@ -4,11 +4,11 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type KefuConnectionStatusProps = {
|
||||
type SupportChatConnectionStatusProps = {
|
||||
status: "connecting" | "connected" | "disconnected"
|
||||
}
|
||||
|
||||
export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) {
|
||||
export function SupportChatConnectionStatus({ status }: SupportChatConnectionStatusProps) {
|
||||
const t = useI18n()
|
||||
const toneClass =
|
||||
status === "connected"
|
||||
@@ -32,7 +32,7 @@ export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) {
|
||||
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
|
||||
)}
|
||||
/>
|
||||
<span>{t(`kefu.${status}`)}</span>
|
||||
<span>{t(`supportChat.${status}`)}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const SUPPORT_DEMO_PATH = "/support/demo"
|
||||
|
||||
export function getWidgetDemoPath() {
|
||||
return SUPPORT_DEMO_PATH
|
||||
}
|
||||
+11
-11
@@ -20,7 +20,7 @@ import { renderIMMessageHTML } from "@/lib/im-message"
|
||||
import { cn, formatDateTime } from "@/lib/utils"
|
||||
import { useI18n } from "@/i18n/provider"
|
||||
|
||||
type KefuMessageListProps = {
|
||||
type SupportChatMessageListProps = {
|
||||
messages?: ImMessage[] | null
|
||||
onNearBottomVisible?: () => void
|
||||
hasMoreOlder?: boolean
|
||||
@@ -28,7 +28,7 @@ type KefuMessageListProps = {
|
||||
onLoadOlder?: () => Promise<void>
|
||||
}
|
||||
|
||||
export type KefuMessageListHandle = {
|
||||
export type SupportChatMessageListHandle = {
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function getTimelineLabel(
|
||||
t: (key: string, values?: Record<string, string | number>) => string
|
||||
) {
|
||||
if (!value) {
|
||||
return t("kefu.justNow")
|
||||
return t("supportChat.justNow")
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -62,13 +62,13 @@ function getTimelineLabel(
|
||||
date.getMinutes()
|
||||
).padStart(2, "0")}`
|
||||
if (currentDayKey === todayDayKey) {
|
||||
return t("kefu.todayAt", { time: timeText })
|
||||
return t("supportChat.todayAt", { time: timeText })
|
||||
}
|
||||
return `${currentDayKey} ${timeText}`
|
||||
}
|
||||
|
||||
export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageListProps>(
|
||||
function KefuMessageList(
|
||||
export const SupportChatMessageList = forwardRef<SupportChatMessageListHandle, SupportChatMessageListProps>(
|
||||
function SupportChatMessageList(
|
||||
{
|
||||
messages,
|
||||
onNearBottomVisible,
|
||||
@@ -232,14 +232,14 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
|
||||
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("kefu.loadingOlder") : t("kefu.loadOlder")}
|
||||
{loadingOlder ? t("supportChat.loadingOlder") : t("supportChat.loadOlder")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{safeMessages.length === 0 ? (
|
||||
<div className="flex min-h-32 items-center justify-center px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
|
||||
{t("kefu.emptyPrompt")}
|
||||
{t("supportChat.emptyPrompt")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -277,7 +277,7 @@ const MessageItem = memo(
|
||||
const t = useI18n()
|
||||
const { open } = useImageLightbox()
|
||||
const isCustomer = message.senderType === "customer"
|
||||
const senderName = isCustomer ? t("kefu.customerSelf") : message.senderName?.trim() || t("kefu.agentLabel")
|
||||
const senderName = isCustomer ? t("supportChat.customerSelf") : message.senderName?.trim() || t("supportChat.agentLabel")
|
||||
const avatarSrc =
|
||||
!isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
|
||||
const htmlContent = renderIMMessageHTML(message)
|
||||
@@ -301,7 +301,7 @@ const MessageItem = memo(
|
||||
<Avatar className="mt-5">
|
||||
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
|
||||
<AvatarFallback className="bg-muted text-muted-foreground">
|
||||
{fallbackName || t("kefu.customerFallback")}
|
||||
{fallbackName || t("supportChat.customerFallback")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : null}
|
||||
@@ -316,7 +316,7 @@ const MessageItem = memo(
|
||||
<span className="font-medium">{senderName}</span>
|
||||
<span>{formatDateTime(message.sentAt)}</span>
|
||||
{isCustomer ? (
|
||||
<span>{message.agentRead ? t("kefu.agentRead") : t("kefu.agentUnread")}</span>
|
||||
<span>{message.agentRead ? t("supportChat.agentRead") : t("supportChat.agentUnread")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
@@ -115,7 +115,7 @@ async function signUserToken(config: WidgetDemoConfig, t: (key: string) => strin
|
||||
.sign(new TextEncoder().encode(secret))
|
||||
}
|
||||
|
||||
export function KefuWidgetDemo() {
|
||||
export function SupportWidgetDemo() {
|
||||
const t = useI18n()
|
||||
const [config, setConfig] = useState<WidgetDemoConfig>({
|
||||
...INITIAL_CONFIG,
|
||||
@@ -193,7 +193,7 @@ export function KefuWidgetDemo() {
|
||||
const configLines = [` channelId: "${config.channelId || ""}"`]
|
||||
if (config.authMode === "jwt") {
|
||||
configLines.push(` async getUserToken() {
|
||||
const res = await fetch("/api/kefu/user-token", { credentials: "include" });
|
||||
const res = await fetch("/api/support/user-token", { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data.userToken;
|
||||
}`)
|
||||
Reference in New Issue
Block a user