diff --git a/assets/Screenshot_1404-09-09_at_12.09.28-c0ad09df-fdf4-429c-867f-323665d55495.png b/assets/Screenshot_1404-09-09_at_12.09.28-c0ad09df-fdf4-429c-867f-323665d55495.png new file mode 100644 index 0000000..e69de29 diff --git a/assets/Screenshot_1404-09-09_at_12.12.10-6f69ff26-de16-4617-be9e-f1e73ac0e26d.png b/assets/Screenshot_1404-09-09_at_12.12.10-6f69ff26-de16-4617-be9e-f1e73ac0e26d.png new file mode 100644 index 0000000..e69de29 diff --git a/src/app/chatbot/components/ChatWindow.tsx b/src/app/chatbot/components/ChatWindow.tsx new file mode 100644 index 0000000..b7556a9 --- /dev/null +++ b/src/app/chatbot/components/ChatWindow.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useChatbotStore } from "../store/ChatbotStore"; +import { ChatMessage } from "../types/Types"; +import { timeAgo } from "@/config/func"; +import { Copy, TickCircle, Clock } from "iconsax-react"; +import { toast } from "@/components/Toast"; +import { SHOP_CONFIG } from "@/config/const"; + +interface MessageBubbleProps { + message: ChatMessage; +} + +function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.type === "user"; + const isSystem = message.type === "system"; + + const handleCopy = () => { + navigator.clipboard.writeText(message.content); + toast("پیام کپی شد", "success"); + }; + + if (isSystem) { + return ( +
+
+ {message.content} +
+
+ ); + } + + return ( +
+
+
+

+ {message.content} +

+
+
+ {timeAgo(message.createdAt)} + {isUser && ( +
+ {message.status === "sent" && ( + + )} + {message.status === "delivered" && ( + + )} + {message.status === "read" && ( + + )} + {message.status === "failed" && ( + + )} +
+ )} +
+
+ {!isUser && ( + + )} +
+ ); +} + +interface StreamingMessageProps { + content: string; +} + +function StreamingMessage({ content }: StreamingMessageProps) { + return ( +
+
+
+

+ {content} + +

+
+
+ در حال تایپ... +
+
+
+ ); +} + +export default function ChatWindow() { + const messagesEndRef = useRef(null); + const { messages, streamingMessage } = useChatbotStore(); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages, streamingMessage]); + + if (messages.length === 0 && !streamingMessage.isStreaming) { + return ( +
+
+

+ خوش آمدید! 👋 +

+

+ سوال خود را در مورد محصولات {SHOP_CONFIG.fullName} بپرسید +

+
+
+ ); + } + + return ( +
+ {messages.map((message) => ( + + ))} + {streamingMessage.isStreaming && ( + + )} +
+
+ ); +} diff --git a/src/app/chatbot/components/ChatbotWidget.tsx b/src/app/chatbot/components/ChatbotWidget.tsx new file mode 100644 index 0000000..ffddf9c --- /dev/null +++ b/src/app/chatbot/components/ChatbotWidget.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useChatbotSocket } from "../hooks/useChatbotSocket"; +import { useChatbotStore } from "../store/ChatbotStore"; +import ChatWindow from "./ChatWindow"; +import MessageInput from "./MessageInput"; +import SessionList from "./SessionList"; +import ConnectionStatus from "./ConnectionStatus"; +import { CloseCircle, Menu } from "iconsax-react"; +import { Button } from "@/components/ui/button"; + +interface ChatbotWidgetProps { + isOpen: boolean; + onClose: () => void; +} + +export default function ChatbotWidget({ isOpen, onClose }: ChatbotWidgetProps) { + const { connect, disconnect, createSession, joinSession, sendMessage, connectionStatus } = + useChatbotSocket(); + const { currentSession } = useChatbotStore(); + const [showSidebar, setShowSidebar] = useState(false); + + useEffect(() => { + if (isOpen) { + connect().then(() => { + // اگر سشن موجود نبود، به صورت خودکار یک سشن جدید ایجاد کن + const { currentSession } = useChatbotStore.getState(); + if (!currentSession) { + createSession(); + } + }); + // جلوگیری از scroll صفحه هنگام باز بودن widget + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + + return () => { + if (!isOpen) { + disconnect(); + document.body.style.overflow = "unset"; + } + }; + }, [isOpen, connect, disconnect, createSession]); + + const handleCreateSession = () => { + createSession(); + }; + + const handleSessionSelect = (sessionId: string) => { + joinSession(sessionId); + if (window.innerWidth < 768) { + setShowSidebar(false); + } + }; + + const handleSendMessage = (content: string) => { + sendMessage(content, true); + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Chat Widget - Positioned above icon (bottom-right) */} +
+ {/* Header */} +
+
+ +
+

+ چت‌بات راهنما +

+
+
+
+ + +
+
+ + {/* Content */} +
+ {/* Sidebar - Desktop */} + {showSidebar && ( +
+ +
+ )} + + {/* Mobile Sidebar Overlay */} + {showSidebar && ( +
+
setShowSidebar(false)} + /> +
+ +
+
+ )} + + {/* Main Chat Area */} +
+ {/* Chat Window */} + + + {/* Message Input */} + +
+
+
+
+ ); +} diff --git a/src/app/chatbot/components/ConnectionStatus.tsx b/src/app/chatbot/components/ConnectionStatus.tsx new file mode 100644 index 0000000..641179c --- /dev/null +++ b/src/app/chatbot/components/ConnectionStatus.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { ConnectionStatus as ConnectionStatusType } from "../types/Types"; +import { TickCircle, CloseCircle } from "iconsax-react"; +import { ComponentType } from "react"; + +interface ConnectionStatusProps { + status: ConnectionStatusType; +} + +type StatusConfig = { + label: string; + color: string; + iconColor: string; + bgColor: string; + showIcon: boolean; + icon?: ComponentType<{ + className?: string; + color?: string; + variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone"; + }>; +}; + +const statusConfig: Record = { + connected: { + label: "متصل", + showIcon: true, + icon: TickCircle as ComponentType<{ + className?: string; + color?: string; + variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone"; + }>, + color: "text-green-500", + iconColor: "#10b981", + bgColor: "bg-green-100 dark:bg-green-900/20", + }, + disconnected: { + label: "قطع شده", + showIcon: true, + icon: CloseCircle as ComponentType<{ + className?: string; + color?: string; + variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone"; + }>, + color: "text-gray-500", + iconColor: "#6b7280", + bgColor: "bg-gray-100 dark:bg-gray-900/20", + }, + connecting: { + label: "در حال اتصال...", + showIcon: false, + color: "text-blue-500", + iconColor: "#3b82f6", + bgColor: "bg-blue-100 dark:bg-blue-900/20", + }, + reconnecting: { + label: "در حال اتصال مجدد...", + showIcon: false, + color: "text-yellow-500", + iconColor: "#eab308", + bgColor: "bg-yellow-100 dark:bg-yellow-900/20", + }, + error: { + label: "خطا در اتصال", + showIcon: true, + icon: CloseCircle as ComponentType<{ + className?: string; + color?: string; + variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone"; + }>, + color: "text-red-500", + iconColor: "#ef4444", + bgColor: "bg-red-100 dark:bg-red-900/20", + }, +}; + +export default function ConnectionStatus({ status }: ConnectionStatusProps) { + const config = statusConfig[status]; + const isSpinning = status === "connecting" || status === "reconnecting"; + + const IconComponent = config.showIcon ? config.icon : null; + + return ( +
+ {isSpinning ? ( +
+ ) : IconComponent ? ( + + ) : null} + {config.label} +
+ ); +} diff --git a/src/app/chatbot/components/FloatingChatButton.tsx b/src/app/chatbot/components/FloatingChatButton.tsx new file mode 100644 index 0000000..ad3fd9f --- /dev/null +++ b/src/app/chatbot/components/FloatingChatButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useState } from "react"; +import { MessageQuestion } from "iconsax-react"; +import ChatbotWidget from "./ChatbotWidget"; + +export default function FloatingChatButton() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + setIsOpen(false)} /> + + ); +} diff --git a/src/app/chatbot/components/MessageInput.tsx b/src/app/chatbot/components/MessageInput.tsx new file mode 100644 index 0000000..04da109 --- /dev/null +++ b/src/app/chatbot/components/MessageInput.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState, useRef, useEffect, KeyboardEvent } from "react"; +import { Send2 } from "iconsax-react"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; + +interface MessageInputProps { + onSend: (message: string) => void; + disabled?: boolean; + placeholder?: string; + maxLength?: number; +} + +export default function MessageInput({ + onSend, + disabled = false, + placeholder = "پیام خود را بنویسید...", + maxLength = 2000, +}: MessageInputProps) { + const [message, setMessage] = useState(""); + const [isTyping, setIsTyping] = useState(false); + const textareaRef = useRef(null); + const typingTimeoutRef = useRef | null>(null); + + useEffect(() => { + // Auto-resize textarea + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + }, [message]); + + const handleSend = () => { + const trimmedMessage = message.trim(); + if (trimmedMessage && !disabled) { + onSend(trimmedMessage); + setMessage(""); + setIsTyping(false); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleChange = (value: string) => { + if (value.length <= maxLength) { + setMessage(value); + setIsTyping(true); + + // Clear previous timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Set new timeout to stop typing indicator after 3 seconds + typingTimeoutRef.current = setTimeout(() => { + setIsTyping(false); + }, 3000); + } + }; + + useEffect(() => { + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + }, []); + + const remainingChars = maxLength - message.length; + const canSend = message.trim().length > 0 && !disabled; + + return ( +
+
+
+