sina_sajjadi
2 weeks ago
31 changed files with 1789 additions and 158 deletions
-
3package.json
-
49public/image/Frame 1000005529.svg
-
3public/image/State=Send.svg
-
3public/image/Vector.svg
-
3public/image/VectorWhite.svg
-
3public/image/bars.svg
-
BINpublic/image/clock-fast-forward.png
-
3public/image/copy-06.svg
-
3public/image/edit-04.svg
-
3public/image/quill_checkmark-double.svg
-
3public/image/search.svg
-
3public/image/trash-03.svg
-
99src/components/chat/audio-message.tsx
-
305src/components/chat/chat-input.tsx
-
32src/components/chat/contact-info.tsx
-
79src/components/chat/contex-menu.tsx
-
173src/components/chat/file-message.tsx
-
50src/components/chat/image-message.tsx
-
267src/components/chat/message.tsx
-
71src/components/chat/messages-list.tsx
-
1src/components/layouts/admin/index.tsx
-
135src/components/layouts/topbar/message-bar.tsx
-
341src/contexts/WebSocket.context.tsx
-
33src/pages/_app.tsx
-
46src/pages/chat/chat-box.tsx
-
59src/pages/chat/contact-info.tsx
-
126src/pages/chat/converstions.tsx
-
32src/pages/chat/index.tsx
-
3src/pages/subscriptions/active-section.tsx
-
4src/pages/subscriptions/history-section.tsx
-
12src/pages/subscriptions/plans-section.tsx
49
public/image/Frame 1000005529.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,3 @@ |
|||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M5.59961 12.75L9.34961 16.5L18.3496 7.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M14.2982 14.1601L1.69824 1.56006M14.2982 1.56006L1.69824 14.1601" stroke="#01353B" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M14.2982 14.1601L1.69824 1.56006M14.2982 1.56006L1.69824 14.1601" stroke="#E6E6E6" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M5.33301 16H26.6663M5.33301 24L26.6663 24M5.33301 8L26.6663 8" stroke="#7D7D7D" stroke-width="1.5" stroke-linecap="round"/> |
||||
|
</svg> |
After Width: 24 | Height: 24 | Size: 650 B |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M7.5 3H14.6C16.8402 3 17.9603 3 18.816 3.43597C19.5686 3.81947 20.1805 4.43139 20.564 5.18404C21 6.03969 21 7.15979 21 9.4V16.5M6.2 21H14.3C15.4201 21 15.9802 21 16.408 20.782C16.7843 20.5903 17.0903 20.2843 17.282 19.908C17.5 19.4802 17.5 18.9201 17.5 17.8V9.7C17.5 8.57989 17.5 8.01984 17.282 7.59202C17.0903 7.21569 16.7843 6.90973 16.408 6.71799C15.9802 6.5 15.4201 6.5 14.3 6.5H6.2C5.0799 6.5 4.51984 6.5 4.09202 6.71799C3.71569 6.90973 3.40973 7.21569 3.21799 7.59202C3 8.01984 3 8.57989 3 9.7V17.8C3 18.9201 3 19.4802 3.21799 19.908C3.40973 20.2843 3.71569 20.5903 4.09202 20.782C4.51984 21 5.0799 21 6.2 21Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M21 18.0002L19.9999 19.0943C19.4695 19.6744 18.7501 20.0002 18.0001 20.0002C17.2501 20.0002 16.5308 19.6744 16.0004 19.0943C15.4692 18.5154 14.75 18.1903 14.0002 18.1903C13.2504 18.1903 12.5311 18.5154 12 19.0943M3 20.0002H4.67454C5.16372 20.0002 5.40832 20.0002 5.63849 19.945C5.84256 19.896 6.03765 19.8152 6.2166 19.7055C6.41843 19.5818 6.59138 19.4089 6.93729 19.063L19.5 6.50023C20.3285 5.6718 20.3285 4.32865 19.5 3.50023C18.6716 2.6718 17.3285 2.6718 16.5 3.50023L3.93726 16.063C3.59136 16.4089 3.4184 16.5818 3.29472 16.7837C3.18506 16.9626 3.10425 17.1577 3.05526 17.3618C3 17.5919 3 17.8365 3 18.3257V20.0002Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M2.2002 12.75L5.9502 16.5L14.9502 7.5M11.2002 15L12.7002 16.5L21.7002 7.5" stroke="#808080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M28 27.9996L23.3461 23.3461M23.3461 23.3461C25.3961 21.2952 26.6641 18.4624 26.6641 15.3333C26.6641 9.07411 21.5905 4 15.332 4C9.07352 4 4 9.07411 4 15.3333C4 21.5926 9.07352 26.6667 15.332 26.6667C18.4619 26.6667 21.2954 25.3977 23.3461 23.3461Z" stroke="#7D7D7D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,3 @@ |
|||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
||||
|
<path d="M9 3H15M3 6H21M19 6L18.2987 16.5193C18.1935 18.0975 18.1409 18.8867 17.8 19.485C17.4999 20.0118 17.0472 20.4353 16.5017 20.6997C15.882 21 15.0911 21 13.5093 21H10.4907C8.90891 21 8.11803 21 7.49834 20.6997C6.95276 20.4353 6.50009 20.0118 6.19998 19.485C5.85911 18.8867 5.8065 18.0975 5.70129 16.5193L5 6M10 10.5V15.5M14 10.5V15.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
@ -0,0 +1,99 @@ |
|||||
|
import { useRef, useState, useEffect } from "react"; |
||||
|
import { FaPlay, FaPause, FaCheckDouble } from "react-icons/fa"; |
||||
|
import { AudioVisualizer } from "react-audio-visualize"; |
||||
|
|
||||
|
const AudioMessage = ({ audio, timestamp = "2:47 PM" }) => { |
||||
|
const audioRef = useRef(null); |
||||
|
const [isPlaying, setIsPlaying] = useState(false); |
||||
|
const [currentTime, setCurrentTime] = useState(0); |
||||
|
const [audioBlob, setAudioBlob] = useState(null); |
||||
|
const isUser = audio.by_user.account_type === "user" |
||||
|
useEffect(() => { |
||||
|
// If audio.content is a URL string, fetch and convert it to Blob
|
||||
|
if (typeof audio.content === 'string') { |
||||
|
fetch(audio.content) |
||||
|
.then(res => res.blob()) |
||||
|
.then(blob => { |
||||
|
setAudioBlob(blob); |
||||
|
}) |
||||
|
.catch(err => console.error("Error fetching audio:", err)); |
||||
|
} else if (audio.content instanceof Blob) { |
||||
|
// If audio.content is already a Blob, just set it
|
||||
|
setAudioBlob(audio.content); |
||||
|
} else { |
||||
|
console.error("audio.content must be a Blob or a URL string."); |
||||
|
} |
||||
|
}, [audio.content]); |
||||
|
|
||||
|
const togglePlay = () => { |
||||
|
if (!audioRef.current) return; |
||||
|
if (isPlaying) { |
||||
|
audioRef.current.pause(); |
||||
|
} else { |
||||
|
audioRef.current.play(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handlePlay = () => setIsPlaying(true); |
||||
|
const handlePause = () => setIsPlaying(false); |
||||
|
const handleEnded = () => { |
||||
|
setIsPlaying(false); |
||||
|
setCurrentTime(0); |
||||
|
}; |
||||
|
|
||||
|
const handleTimeUpdate = () => { |
||||
|
if (audioRef.current) { |
||||
|
setCurrentTime(audioRef.current.currentTime); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Create a URL for the audio element if we have a blob
|
||||
|
const audioUrl = audioBlob ? URL.createObjectURL(audioBlob) : null; |
||||
|
|
||||
|
return ( |
||||
|
<div className={`flex items-center gap-2`}> |
||||
|
{/* Play/Pause Button */} |
||||
|
<button |
||||
|
onClick={togglePlay} |
||||
|
className="flex items-center justify-center w-12 h-12 rounded-full bg-[#464646] hover:bg-gray-600" |
||||
|
aria-label={isPlaying ? "Pause audio" : "Play audio"} |
||||
|
> |
||||
|
{isPlaying ? <FaPause /> : <FaPlay />} |
||||
|
</button> |
||||
|
|
||||
|
{/* Audio Visualization */} |
||||
|
<div className="flex-1 flex flex-col justify-center items-center"> |
||||
|
{audioBlob && ( |
||||
|
<AudioVisualizer |
||||
|
blob={audioBlob} |
||||
|
width={160} |
||||
|
height={30} |
||||
|
barWidth={3} |
||||
|
gap={4} |
||||
|
barColor="#747474" |
||||
|
barPlayedColor="rgb(255,255,255)" |
||||
|
currentTime={currentTime} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* Timestamp and Status */} |
||||
|
|
||||
|
|
||||
|
{/* Hidden Audio Element */} |
||||
|
{audioUrl && ( |
||||
|
<audio |
||||
|
ref={audioRef} |
||||
|
src={audio.content} |
||||
|
onPlay={handlePlay} |
||||
|
onPause={handlePause} |
||||
|
onEnded={handleEnded} |
||||
|
onTimeUpdate={handleTimeUpdate} |
||||
|
controls={false} // hide default controls, using custom play/pause
|
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default AudioMessage; |
@ -0,0 +1,305 @@ |
|||||
|
import { PiTrash } from "react-icons/pi"; |
||||
|
import Image from "next/image"; |
||||
|
import SendIcon from "public/assets/images/send-01.svg"; |
||||
|
import MicIcon from "public/assets/images/microphone-01.svg"; |
||||
|
import LinkIcon from "public/assets/images/link-simple.svg"; |
||||
|
import SmileIcon from "public/assets/images/face-smile.svg"; |
||||
|
import { MdClose } from "react-icons/md"; |
||||
|
import { useEffect, useState, useRef } from "react"; |
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import FileInput from "../ui/file-input"; |
||||
|
import dynamic from "next/dynamic"; |
||||
|
import EmojiPicker from "emoji-picker-react"; |
||||
|
import { BsFillReplyFill } from "react-icons/bs"; |
||||
|
// Dynamically import ReactMediaRecorder to prevent SSR issues
|
||||
|
const ReactMediaRecorder = dynamic( |
||||
|
() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), |
||||
|
{ ssr: false } |
||||
|
); |
||||
|
|
||||
|
// Helper function to format recording time
|
||||
|
const formatTime = (seconds) => { |
||||
|
const mins = Math.floor(seconds / 60) |
||||
|
.toString() |
||||
|
.padStart(2, "0"); |
||||
|
const secs = (seconds % 60).toString().padStart(2, "0"); |
||||
|
return `${mins}:${secs}`; |
||||
|
}; |
||||
|
|
||||
|
const Input = ({ product = {} }) => { |
||||
|
const { |
||||
|
sendMessage, |
||||
|
setLoadingMessage, |
||||
|
editingMessage, |
||||
|
setEditingMessage, |
||||
|
editMessage, |
||||
|
} = useWebSocket(); |
||||
|
|
||||
|
// State variables
|
||||
|
const [showProduct, setShowProduct] = useState(!!product.id); |
||||
|
const [isRecording, setIsRecording] = useState(false); |
||||
|
const [recordingTime, setRecordingTime] = useState(0); |
||||
|
const [message, setMessage] = useState(""); |
||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false); // Emoji picker state
|
||||
|
|
||||
|
// Ref for the timer interval
|
||||
|
const timerRef = useRef(null); |
||||
|
const messageInputRef = useRef(null); |
||||
|
const emojiPickerRef = useRef(null); |
||||
|
const emojiButtonRef = useRef(null); // Ref for the emoji toggle button
|
||||
|
|
||||
|
// Ref for sendRecording flag
|
||||
|
const sendRecordingRef = useRef(false); |
||||
|
|
||||
|
// Handle sending text messages
|
||||
|
const handleSendMessage = () => { |
||||
|
if (message.trim()) { |
||||
|
if (Object.keys(editingMessage).length) { |
||||
|
const msg = { |
||||
|
content: message, |
||||
|
id: editingMessage.id, |
||||
|
}; |
||||
|
editMessage(msg); |
||||
|
setEditingMessage({}) |
||||
|
setMessage(""); |
||||
|
} else { |
||||
|
sendMessage(message, product?.id); |
||||
|
setMessage(""); |
||||
|
setShowProduct(false); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Start the recording timer
|
||||
|
const startTimer = () => { |
||||
|
setRecordingTime(0); |
||||
|
timerRef.current = setInterval(() => { |
||||
|
setRecordingTime((prevTime) => prevTime + 1); |
||||
|
}, 1000); |
||||
|
}; |
||||
|
|
||||
|
// Stop the recording timer
|
||||
|
const stopTimer = () => { |
||||
|
if (timerRef.current) { |
||||
|
clearInterval(timerRef.current); |
||||
|
timerRef.current = null; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// Cleanup on component unmount
|
||||
|
useEffect(() => { |
||||
|
return () => { |
||||
|
stopTimer(); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
// Handle click outside to close the emoji picker
|
||||
|
useEffect(() => { |
||||
|
const handleClickOutside = (event) => { |
||||
|
if ( |
||||
|
emojiPickerRef.current && |
||||
|
!emojiPickerRef.current.contains(event.target) && |
||||
|
messageInputRef.current && |
||||
|
!messageInputRef.current.contains(event.target) && |
||||
|
emojiButtonRef.current && |
||||
|
!emojiButtonRef.current.contains(event.target) // Ignore clicks on the emoji button
|
||||
|
) { |
||||
|
setShowEmojiPicker(false); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
document.addEventListener("mousedown", handleClickOutside); |
||||
|
return () => { |
||||
|
document.removeEventListener("mousedown", handleClickOutside); |
||||
|
}; |
||||
|
}, []); |
||||
|
useEffect(() => { |
||||
|
if (Object.keys(editingMessage).length) { |
||||
|
setMessage(editingMessage.content); |
||||
|
messageInputRef.current?.focus(); // Set focus on the input
|
||||
|
} |
||||
|
}, [editingMessage]); |
||||
|
|
||||
|
return ( |
||||
|
<ReactMediaRecorder |
||||
|
audio |
||||
|
onStart={() => { |
||||
|
setIsRecording(true); |
||||
|
sendRecordingRef.current = false; // Reset the flag on start
|
||||
|
startTimer(); |
||||
|
}} |
||||
|
onStop={(blobUrl, blob) => { |
||||
|
setIsRecording(false); |
||||
|
stopTimer(); |
||||
|
if (sendRecordingRef.current) { |
||||
|
let selectedFile = blob; |
||||
|
selectedFile.status = "loading"; |
||||
|
selectedFile.name = `audio ${new Date()}.wav`; |
||||
|
setLoadingMessage((prev) => [...prev, selectedFile]); |
||||
|
} else { |
||||
|
console.log("Recording discarded:", blobUrl); |
||||
|
} |
||||
|
sendRecordingRef.current = false; // Reset the flag after handling
|
||||
|
}} |
||||
|
render={({ startRecording, stopRecording }) => ( |
||||
|
<div |
||||
|
className={`${ |
||||
|
product.id && showProduct ? "mb-28" : "" |
||||
|
} z-10 m-7 border border-[#D9D9D9] self-end w-full`}
|
||||
|
> |
||||
|
{/* Product Preview */} |
||||
|
{product.id && showProduct && ( |
||||
|
<div className="w-full flex gap-2 bg-white border p-4"> |
||||
|
<Image src={LinkIcon} alt="Link Icon" width={24} height={24} /> |
||||
|
<div className="flex justify-between w-full items-center"> |
||||
|
<div className="flex gap-2"> |
||||
|
{product?.images && product.images.length > 0 && ( |
||||
|
<Image |
||||
|
width={35} |
||||
|
height={35} |
||||
|
src={product.images[0].image_url.sm} |
||||
|
alt={product.name || "Product Image"} |
||||
|
className="object-cover rounded" |
||||
|
/> |
||||
|
)} |
||||
|
<div className="flex flex-col justify-between"> |
||||
|
<p className="text-xs leading-none mb-0"> |
||||
|
{product.name || "Unnamed Product"} |
||||
|
</p> |
||||
|
<p className="text-[10px]">{product.price}$</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<MdClose |
||||
|
onClick={() => {setShowProduct(false)}} |
||||
|
size={20} |
||||
|
aria-label="Close product preview" |
||||
|
className="cursor-pointer" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{!!Object.keys(editingMessage).length && ( |
||||
|
<div className=" w-full flex bg-white border gap-2 p-4"> |
||||
|
<BsFillReplyFill size={24} /> |
||||
|
|
||||
|
<div className="flex justify-between w-full items-center"> |
||||
|
<div className="flex gap-2"> |
||||
|
<div className="flex flex-col justify-between"> |
||||
|
<p className="text-xs leading-none mb-0"> |
||||
|
{editingMessage.mime_type === "text" |
||||
|
? editingMessage.content |
||||
|
: editingMessage.mime_type} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<MdClose |
||||
|
onClick={() => {setEditingMessage({}); setMessage("")}} |
||||
|
size={20} |
||||
|
aria-label="Close product preview" |
||||
|
className="cursor-pointer" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
{/* Message Input Area */} |
||||
|
<div className="relative h-14 max-h-40 z-10 border-2 bg-white flex items-center p-1"> |
||||
|
{/* Emoji Button */} |
||||
|
<button |
||||
|
ref={emojiButtonRef} // Attach ref to the emoji toggle button
|
||||
|
onClick={() => { |
||||
|
setShowEmojiPicker((prev) => !prev); // Toggle emoji picker state
|
||||
|
}} |
||||
|
className="text-white px-3 py-2 rounded-md transition flex items-center hover:bg-slate-100" |
||||
|
aria-label="Insert emoji" |
||||
|
> |
||||
|
<Image width={25} height={25} src={SmileIcon} alt="Smile" /> |
||||
|
</button> |
||||
|
|
||||
|
{/* Emoji Picker */} |
||||
|
{showEmojiPicker && ( |
||||
|
<div |
||||
|
ref={emojiPickerRef} |
||||
|
className="absolute bottom-14 left-0 z-20 bg-white border rounded shadow-md" |
||||
|
> |
||||
|
<EmojiPicker |
||||
|
onEmojiClick={(emojiObject) => { |
||||
|
setMessage( |
||||
|
(prevMessage) => prevMessage + emojiObject.emoji |
||||
|
); |
||||
|
messageInputRef.current?.focus(); |
||||
|
}} |
||||
|
emojiStyle="native" |
||||
|
skinTonesDisabled |
||||
|
previewConfig={{ |
||||
|
showPreview: false, |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Textarea Input */} |
||||
|
<textarea |
||||
|
className="flex-1 h-full rounded-md p-2 text-base focus:outline-none resize-none overflow-hidden" |
||||
|
ref={messageInputRef} |
||||
|
placeholder="Type your message..." |
||||
|
value={message} |
||||
|
onChange={(e) => setMessage(e.target.value)} |
||||
|
rows={1} |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === "Enter" && !e.shiftKey) { |
||||
|
handleSendMessage(); |
||||
|
e.preventDefault(); |
||||
|
} |
||||
|
}} |
||||
|
aria-label="Message input" |
||||
|
/> |
||||
|
|
||||
|
{/* Send Button or File/Mic Buttons */} |
||||
|
{message.trim() || isRecording ? ( |
||||
|
<button |
||||
|
onClick={() => { |
||||
|
if (isRecording) { |
||||
|
sendRecordingRef.current = true; // Indicate sending
|
||||
|
stopRecording(); |
||||
|
} else { |
||||
|
handleSendMessage(); |
||||
|
} |
||||
|
}} |
||||
|
className="text-white px-3 py-2 rounded-md transition flex items-center hover:bg-slate-100" |
||||
|
aria-label={isRecording ? "Send recording" : "Send message"} |
||||
|
> |
||||
|
<Image src={SendIcon} alt="Send" width={24} height={24} /> |
||||
|
</button> |
||||
|
) : ( |
||||
|
<> |
||||
|
{!isRecording && ( |
||||
|
<> |
||||
|
<FileInput /> |
||||
|
<button |
||||
|
onClick={() => { |
||||
|
startRecording(); |
||||
|
}} |
||||
|
className="text-white px-3 py-2 rounded-md transition flex items-center hover:bg-slate-100" |
||||
|
aria-label="Start recording" |
||||
|
> |
||||
|
<Image |
||||
|
src={MicIcon} |
||||
|
width={23} |
||||
|
height={23} |
||||
|
alt="Microphone" |
||||
|
className="object-contain" |
||||
|
/> |
||||
|
</button> |
||||
|
</> |
||||
|
)} |
||||
|
</> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default Input; |
@ -0,0 +1,32 @@ |
|||||
|
import { useWebSocket } from '@/contexts/WebSocket.context'; |
||||
|
import Image from 'next/image'; |
||||
|
import React from 'react'; |
||||
|
|
||||
|
const ContactInfo = () => { |
||||
|
|
||||
|
const {roomInfo} = useWebSocket() |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex justify-between items-center w-full h-24 p-6 z-10 bg-white border-b-[1px] border-b-[#D9D9D9]"> |
||||
|
{/* Left side: Contact image and name */} |
||||
|
<div className="flex items-center"> |
||||
|
<img |
||||
|
src={roomInfo?.merchant?.logo} |
||||
|
alt="Contact Avatar" |
||||
|
className="w-12 h-12 rounded-full mr-3 object-cover border border-[#EDEDED]" |
||||
|
/> |
||||
|
<div className="flex flex-col"> |
||||
|
<p className="text-base font-semibold mb-0">{roomInfo?.merchant?.title}</p> |
||||
|
<p className="text-xs text-gray-500">{roomInfo?.merchant?.lastSeen ?? "Last Seen Recently"}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Right side: Three dots menu */} |
||||
|
<div className="flex items-center"> |
||||
|
<button className="text-2xl hover:text-gray-700 rotate-90">...</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ContactInfo; |
@ -0,0 +1,79 @@ |
|||||
|
// src/components/ContextMenu.tsx
|
||||
|
import { motion, AnimatePresence } from "framer-motion"; |
||||
|
import Image from "next/image"; |
||||
|
import Trash from "../../../public/image/trash-03.svg"; |
||||
|
import Copy from "../../../public/image/copy-06.svg"; |
||||
|
import Edit from "../../../public/image/edit-04.svg"; |
||||
|
|
||||
|
interface ContextMenuProps { |
||||
|
isVisible: boolean; |
||||
|
onCopy: () => void; |
||||
|
onEdit: () => void; |
||||
|
onDelete: () => void; |
||||
|
menuRef: React.RefObject<HTMLDivElement>; |
||||
|
className?: string; |
||||
|
type: string; |
||||
|
isUser: boolean; |
||||
|
} |
||||
|
|
||||
|
const ContextMenu = ({ |
||||
|
isVisible, |
||||
|
onCopy, |
||||
|
onEdit, |
||||
|
onDelete, |
||||
|
menuRef, |
||||
|
className = "", |
||||
|
type, |
||||
|
isUser |
||||
|
}: ContextMenuProps) => { |
||||
|
return ( |
||||
|
<AnimatePresence> |
||||
|
{isVisible && ( |
||||
|
<motion.div |
||||
|
ref={menuRef} |
||||
|
className={`absolute bottom-16 bg-[#323232] w-56 rounded-xl p-4 my-2 z-20 ${className}`} |
||||
|
initial={{ opacity: 0, y: 10 }} // Initial state for animation
|
||||
|
animate={{ opacity: 1, y: 0 }} // Final state when visible
|
||||
|
exit={{ opacity: 0, y: 10 }} // Animation when exiting
|
||||
|
transition={{ duration: 0.2, ease: "easeOut" }} // Smooth transition
|
||||
|
role="menu" |
||||
|
aria-orientation="vertical" |
||||
|
aria-label="Message options" |
||||
|
> |
||||
|
<button |
||||
|
className="flex items-center gap-2 text-white p-2 rounded-lg hover:bg-[#464646] cursor-pointer w-full text-left" |
||||
|
onClick={onCopy} |
||||
|
role="menuitem" |
||||
|
> |
||||
|
<Image src={Copy} alt="Copy" /> |
||||
|
<span className="text-xl">Copy</span> |
||||
|
</button> |
||||
|
{isUser && ( |
||||
|
<> |
||||
|
{type === "text" && ( |
||||
|
<button |
||||
|
className="flex items-center gap-2 text-white p-2 rounded-lg hover:bg-[#464646] cursor-pointer w-full text-left my-2" |
||||
|
onClick={onEdit} |
||||
|
role="menuitem" |
||||
|
> |
||||
|
<Image src={Edit} alt="Edit" /> |
||||
|
<span className="text-xl">Edit</span> |
||||
|
</button> |
||||
|
)} |
||||
|
<button |
||||
|
className="flex items-center gap-2 text-white p-2 rounded-lg hover:bg-[#464646] cursor-pointer w-full text-left" |
||||
|
onClick={onDelete} |
||||
|
role="menuitem" |
||||
|
> |
||||
|
<Image src={Trash} alt="Delete" /> |
||||
|
<span className="text-xl">Delete</span> |
||||
|
</button> |
||||
|
</> |
||||
|
)} |
||||
|
</motion.div> |
||||
|
)} |
||||
|
</AnimatePresence> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ContextMenu; |
@ -0,0 +1,173 @@ |
|||||
|
import { useEffect, useState } from "react"; |
||||
|
import Image from "next/image"; |
||||
|
import { FaFileAlt, FaFileArchive, FaFile } from "react-icons/fa"; |
||||
|
import { FaFileCode } from "react-icons/fa6"; |
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import { HttpClient as http } from "@/data/client/http-client"; |
||||
|
import Stop from "../../../public/image/Vector.svg"; |
||||
|
import StopWhite from "../../../public/image/VectorWhite.svg"; |
||||
|
|
||||
|
// File type extensions
|
||||
|
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "svg"]; |
||||
|
const VIDEO_EXTENSIONS = ["mp4", "mkv", "webm", "avi", "mov", "flv", "wmv"]; |
||||
|
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a"]; |
||||
|
|
||||
|
const fileTypes = { |
||||
|
document: ["pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "txt", "rtf"], |
||||
|
compressed: ["zip", "rar", "tar", "gz", "7z"], |
||||
|
code: ["js", "css", "html", "json", "xml", "csv"], |
||||
|
}; |
||||
|
|
||||
|
const FileMessage = ({ file }) => { |
||||
|
const { sendFile } = useWebSocket(); |
||||
|
const [percentage, setPercentage] = useState(1); |
||||
|
const isLoading = file.status === "loading"; |
||||
|
const radius = 70; // Circle radius for loading animation
|
||||
|
const circumference = 2 * Math.PI * radius; |
||||
|
|
||||
|
// Extract the file name for display
|
||||
|
const fileName = isLoading |
||||
|
? file.name |
||||
|
: file.content.split("/").pop().split("-").slice(1).join("-"); |
||||
|
const strokeDashoffset = circumference - (percentage / 100) * circumference; |
||||
|
|
||||
|
const getFileType = (extension) => { |
||||
|
const ext = extension.toLowerCase(); |
||||
|
if (IMAGE_EXTENSIONS.includes(ext)) return "image"; |
||||
|
if (VIDEO_EXTENSIONS.includes(ext)) return "video"; |
||||
|
if (AUDIO_EXTENSIONS.includes(ext)) return "audio"; |
||||
|
return "file"; |
||||
|
}; |
||||
|
|
||||
|
const getFileIcon = (ext) => { |
||||
|
if (fileTypes.document.includes(ext)) return <FaFileAlt size={24} />; |
||||
|
if (fileTypes.code.includes(ext)) return <FaFileCode size={24} />; |
||||
|
if (fileTypes.compressed.includes(ext)) return <FaFileArchive />; |
||||
|
return <FaFile size={24} />; |
||||
|
}; |
||||
|
|
||||
|
const formatBytes = (bytes, decimals = 2) => { |
||||
|
if (!bytes) return "0 Bytes"; |
||||
|
const k = 1024; |
||||
|
const dm = decimals < 0 ? 0 : decimals; |
||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; |
||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; |
||||
|
}; |
||||
|
|
||||
|
const handleDownload = () => { |
||||
|
if (file.content) { |
||||
|
const link = document.createElement("a"); |
||||
|
link.href = file.content; |
||||
|
link.download = file.name || "download"; |
||||
|
link.target = "_blank"; |
||||
|
document.body.appendChild(link); |
||||
|
link.click(); |
||||
|
document.body.removeChild(link); |
||||
|
} else { |
||||
|
console.error("File URL is not available."); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const handleClose = () => { |
||||
|
console.log("Close button clicked"); |
||||
|
}; |
||||
|
|
||||
|
const fileExtension = isLoading |
||||
|
? file.name.split(".").pop() |
||||
|
: file.content.split("/").pop().split(".").pop(); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (isLoading) { |
||||
|
const formData = new FormData(); |
||||
|
formData.append("file", file, file.name); |
||||
|
http |
||||
|
.post("https://mesbahi.nwhco.ir/api/upload-tmp-media/", formData, { |
||||
|
headers: { "Content-Type": "multipart/form-data" }, |
||||
|
onUploadProgress: (event) => { |
||||
|
if (event.total) { |
||||
|
const currentPercentage = Math.round( |
||||
|
(event.loaded * 100) / event.total |
||||
|
); |
||||
|
setPercentage(currentPercentage); |
||||
|
} |
||||
|
}, |
||||
|
}) |
||||
|
.then((res) => { |
||||
|
const fileType = getFileType(fileExtension); |
||||
|
sendFile({ ...res.data, type: fileType }); |
||||
|
}) |
||||
|
.catch((err) => console.error(err)); |
||||
|
} |
||||
|
}, [file]); |
||||
|
|
||||
|
if (isLoading) { |
||||
|
return ( |
||||
|
<div className="flex gap-3 p-3 self-end w-64 rounded-t-[10px] rounded-r-[10px] bg-[#323232] text-white"> |
||||
|
<div className="relative"> |
||||
|
<svg |
||||
|
className="w-12 h-12 transform rounded-full -rotate-90" |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
viewBox="0 0 150 150" |
||||
|
> |
||||
|
{/* Background Circle */} |
||||
|
<circle |
||||
|
cx="75" |
||||
|
cy="75" |
||||
|
r={radius} |
||||
|
stroke={isLoading ? "#EEEFF2" : "#e6e6e6"} |
||||
|
strokeWidth="10" |
||||
|
fill="#464646" |
||||
|
/> |
||||
|
{/* Progress Circle */} |
||||
|
<circle |
||||
|
cx="75" |
||||
|
cy="75" |
||||
|
r={radius} |
||||
|
stroke={isLoading ? "#fff" : "#01353B"} |
||||
|
strokeWidth="10" |
||||
|
fill="none" |
||||
|
strokeDasharray={circumference} |
||||
|
strokeDashoffset={strokeDashoffset} |
||||
|
/> |
||||
|
</svg> |
||||
|
<button |
||||
|
onClick={handleClose} |
||||
|
className="absolute top-0 left-0 w-full h-full flex items-center justify-center text-white text-xl font-bold" |
||||
|
> |
||||
|
<Image |
||||
|
className="text-white" |
||||
|
src={isLoading ? StopWhite : Stop} |
||||
|
alt="Stop upload" |
||||
|
/> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div className="flex flex-col justify-between"> |
||||
|
<p className="text-base font-bold mb-0 overflow-hidden text-ellipsis whitespace-nowrap w-44"> |
||||
|
{fileName} |
||||
|
</p> |
||||
|
<p className="font-thin text-xs opacity-60">{formatBytes(file?.size)}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="flex gap-3"> |
||||
|
<button |
||||
|
onClick={handleDownload} |
||||
|
className="top-0 left-0 w-12 h-12 bg-[#EEEFF2] rounded-full flex items-center justify-center text-white text-xl font-bold" |
||||
|
> |
||||
|
{getFileIcon(fileExtension)} |
||||
|
</button> |
||||
|
<div className="flex flex-col justify-between"> |
||||
|
<p className="text-base font-bold mb-0 overflow-hidden text-ellipsis whitespace-nowrap w-44"> |
||||
|
{fileName} |
||||
|
</p> |
||||
|
<p className="font-thin text-xs opacity-60">{formatBytes(file?.size)}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default FileMessage; |
@ -0,0 +1,50 @@ |
|||||
|
import Image from "next/image"; |
||||
|
import { useRef, useState } from "react"; |
||||
|
import { FaPlay } from "react-icons/fa"; |
||||
|
|
||||
|
const ImageMessage = ({ image }) => { |
||||
|
const [controls, setControls] = useState(false); |
||||
|
const video = useRef(null); |
||||
|
|
||||
|
const handlePlayClick = () => { |
||||
|
setControls(true); |
||||
|
video.current.play(); // Play the video when the play button is clicked
|
||||
|
}; |
||||
|
|
||||
|
const isUser = image?.by_user?.account_type === "user"; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
{image.mime_type === "video" ? ( |
||||
|
<div |
||||
|
onClick={handlePlayClick} |
||||
|
className="relative cursor-pointer" |
||||
|
> |
||||
|
{/* Play Button Overlay */} |
||||
|
{!controls && ( |
||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 p-4 bg-[#212121] rounded-full opacity-30"> |
||||
|
<FaPlay className="text-white" /> |
||||
|
</div> |
||||
|
)} |
||||
|
<video |
||||
|
ref={video} |
||||
|
className="w-full h-full" |
||||
|
controls={controls} |
||||
|
muted={!controls} // Muted by default to avoid autoplay blocking
|
||||
|
src={image.content} |
||||
|
/> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<Image |
||||
|
alt="Image" |
||||
|
width={300} |
||||
|
height={200} |
||||
|
className="object-scale-down" |
||||
|
src={image.content} |
||||
|
/> |
||||
|
)} |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ImageMessage; |
@ -0,0 +1,267 @@ |
|||||
|
import { useState, useEffect, useCallback, useRef } from "react"; |
||||
|
import { useRouter } from "next/router"; |
||||
|
import ContextMenu from "./contex-menu"; // Import the ContextMenu component
|
||||
|
import Image from "next/image"; |
||||
|
import Seen from "public/image/quill_checkmark-double.svg"; |
||||
|
import UnSeen from "public/image/State=Send.svg"; |
||||
|
import Pending from "public/image/clock-fast-forward.png"; |
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import Button from "../ui/button"; |
||||
|
import { Routes as ROUTES } from "@/config/routes"; |
||||
|
|
||||
|
import { HttpClient as http } from "@/data/client/http-client"; |
||||
|
import FileMessage from "./file-message"; |
||||
|
import ImageMessage from "./image-message"; |
||||
|
import AudioMessage from "./audio-message"; |
||||
|
|
||||
|
const Message = ({ msg }) => { |
||||
|
const isFileMessage = msg.status || msg?.mime_type === "file" ? true : false; |
||||
|
const isLoadingMessage = msg.status ? true : false; |
||||
|
const isAudioMessage = msg?.mime_type === "audio" ? true : false; |
||||
|
const isImageMessage = |
||||
|
msg?.mime_type === "image" || msg?.mime_type === "video" ? true : false; |
||||
|
const isTextMessage = msg.mime_type === "text" ? true : false; |
||||
|
if (isLoadingMessage) { |
||||
|
return <FileMessage file={msg} />; |
||||
|
} |
||||
|
|
||||
|
// if (isImageMessage) {
|
||||
|
|
||||
|
// return
|
||||
|
// }
|
||||
|
// if (isAudioMessage) {
|
||||
|
|
||||
|
// return <AudioMessage audio={msg} />
|
||||
|
// }
|
||||
|
const [showMenu, setShowMenu] = useState(false); |
||||
|
const [data, setData] = useState({}); |
||||
|
|
||||
|
const messageRef = useRef(null); |
||||
|
const menuRef = useRef(null); |
||||
|
const { roomInfo, setEditingMessage, deleteMessage } = useWebSocket(); |
||||
|
const router = useRouter(); |
||||
|
|
||||
|
// Format time as "hh:mm AM/PM"
|
||||
|
const formatTime = useCallback((timestamp = Date.now()) => { |
||||
|
const date = new Date(timestamp); |
||||
|
let hours = date.getHours(); |
||||
|
const minutes = date.getMinutes(); |
||||
|
const ampm = hours >= 12 ? "PM" : "AM"; |
||||
|
|
||||
|
hours %= 12; |
||||
|
hours = hours || 12; // Convert '0' to '12'
|
||||
|
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes; |
||||
|
|
||||
|
return `${hours}:${formattedMinutes} ${ampm}`; |
||||
|
}, []); |
||||
|
|
||||
|
|
||||
|
// Handlers for context menu actions
|
||||
|
const handleCopy = useCallback(() => { |
||||
|
navigator.clipboard |
||||
|
.writeText(typeof msg === "string" ? msg : msg.content) |
||||
|
.then(() => { |
||||
|
console.log("Copied to clipboard"); |
||||
|
}) |
||||
|
.catch((err) => { |
||||
|
console.error("Failed to copy: ", err); |
||||
|
}); |
||||
|
setShowMenu(false); |
||||
|
}, [msg]); |
||||
|
|
||||
|
const handleEdit = useCallback(() => { |
||||
|
setEditingMessage(msg); |
||||
|
setShowMenu(false); |
||||
|
}, []); |
||||
|
|
||||
|
const handleDelete = useCallback(() => { |
||||
|
deleteMessage(msg); |
||||
|
setShowMenu(false); |
||||
|
}, []); |
||||
|
|
||||
|
// Toggle menu visibility on right-click
|
||||
|
const handleContextMenu = useCallback( |
||||
|
(e) => { |
||||
|
e.preventDefault(); |
||||
|
setShowMenu((prev) => !prev); |
||||
|
}, |
||||
|
[setShowMenu] |
||||
|
); |
||||
|
|
||||
|
// Close menu if clicked outside
|
||||
|
const handleClickOutside = useCallback( |
||||
|
(e) => { |
||||
|
if ( |
||||
|
menuRef.current && |
||||
|
!menuRef.current.contains(e.target) && |
||||
|
messageRef.current && |
||||
|
!messageRef.current.contains(e.target) |
||||
|
) { |
||||
|
setShowMenu(false); |
||||
|
} |
||||
|
}, |
||||
|
[menuRef, messageRef] |
||||
|
); |
||||
|
|
||||
|
// Handle escape key to close menu
|
||||
|
const handleKeyDown = useCallback( |
||||
|
(e) => { |
||||
|
if (e.key === "Escape") { |
||||
|
setShowMenu(false); |
||||
|
} |
||||
|
}, |
||||
|
[setShowMenu] |
||||
|
); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (showMenu) { |
||||
|
document.addEventListener("mousedown", handleClickOutside); |
||||
|
document.addEventListener("keydown", handleKeyDown); |
||||
|
} else { |
||||
|
document.removeEventListener("mousedown", handleClickOutside); |
||||
|
document.removeEventListener("keydown", handleKeyDown); |
||||
|
} |
||||
|
|
||||
|
// Cleanup on unmount
|
||||
|
return () => { |
||||
|
document.removeEventListener("mousedown", handleClickOutside); |
||||
|
document.removeEventListener("keydown", handleKeyDown); |
||||
|
}; |
||||
|
}, [showMenu, handleClickOutside, handleKeyDown]); |
||||
|
|
||||
|
const isStringMessage = typeof msg === "string"; |
||||
|
|
||||
|
const messageContent = isStringMessage ? msg : msg.content; |
||||
|
const messageKey = isStringMessage ? msg : msg.id; |
||||
|
const messageTime = isStringMessage |
||||
|
? formatTime() |
||||
|
: formatTime(msg.created_at); |
||||
|
const isUser = !isStringMessage && msg?.by_user?.account_type === "user"; |
||||
|
const isSeen = !isStringMessage && msg?.is_read; |
||||
|
const product = |
||||
|
!isStringMessage && Object.keys(msg?.product_reply).length |
||||
|
? msg.product_reply |
||||
|
: false; |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (product) { |
||||
|
http.get(`shop/products/${product.slug}/`).then((res) => { |
||||
|
setData(res.data); |
||||
|
}); |
||||
|
} |
||||
|
}, [product]); |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
ref={messageRef} |
||||
|
onContextMenu={handleContextMenu} |
||||
|
onClick={(e) => { |
||||
|
e.stopPropagation(); |
||||
|
}} |
||||
|
key={messageKey} |
||||
|
className={`rounded-lg border p-2 mb-2 max-w-[525px] flex flex-col whitespace-pre-line break-words relative ${ |
||||
|
isUser |
||||
|
? "self-end bg-[#323232] text-white" |
||||
|
: isStringMessage |
||||
|
? "self-end bg-[#323232] text-white" |
||||
|
: "self-start bg-white text-gray-800" |
||||
|
}`}
|
||||
|
tabIndex={0} |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { |
||||
|
e.preventDefault(); |
||||
|
setShowMenu(true); |
||||
|
} |
||||
|
}} |
||||
|
aria-haspopup="true" |
||||
|
aria-expanded={showMenu} |
||||
|
> |
||||
|
<ContextMenu |
||||
|
type={msg.mime_type} |
||||
|
isVisible={showMenu} |
||||
|
onCopy={handleCopy} |
||||
|
onEdit={handleEdit} |
||||
|
onDelete={handleDelete} |
||||
|
menuRef={menuRef} |
||||
|
isUser={isUser} |
||||
|
className={isUser ? "-right-2" : ""} |
||||
|
/> |
||||
|
|
||||
|
{/* Render file message */} |
||||
|
|
||||
|
{product && ( |
||||
|
<div |
||||
|
className={`w-[510px] border rounded-lg mb-4 ${ |
||||
|
isUser |
||||
|
? "border-[#626262] bg-[#3F3F3F] text-[#F2F2F2]" |
||||
|
: "border-[#D8D8D8] bg-[#F2F2F2] text-black " |
||||
|
} p-3`}
|
||||
|
> |
||||
|
<div className="flex mb-4"> |
||||
|
<div> |
||||
|
<Image |
||||
|
className="rounded-lg" |
||||
|
width={130} |
||||
|
height={130} |
||||
|
src={product?.get_first_image} |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="flex flex-col justify-between"> |
||||
|
<p |
||||
|
className={`font-bold text-2xl w-96 mb-0 px-3 overflow-hidden text-ellipsis whitespace-nowrap`} |
||||
|
> |
||||
|
{product?.name} |
||||
|
</p> |
||||
|
<p className="font-medium text-xl opacity-60 px-3 mb-0 overflow-hidden text-ellipsis whitespace-nowrap w-96"> |
||||
|
{roomInfo?.merchant.title} |
||||
|
</p> |
||||
|
<p className="font-bold text-2xl mb-0 px-3 overflow-hidden text-ellipsis whitespace-nowrap"> |
||||
|
{product?.price}$ |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex gap-4 justify-between"> |
||||
|
<Button |
||||
|
onClick={() => { |
||||
|
router.push(`${ROUTES.PRODUCT}/${product.slug}`, undefined, { |
||||
|
locale: router.locale, |
||||
|
}); |
||||
|
}} |
||||
|
className="w-full bg-[#C5C5C5]" |
||||
|
> |
||||
|
Product details |
||||
|
</Button> |
||||
|
<Button |
||||
|
className={`w-full ${ |
||||
|
isUser && "bg-white" |
||||
|
} !text-black hover:!text-white`}
|
||||
|
> |
||||
|
Add to cart |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
{isImageMessage && <ImageMessage image={msg} />} |
||||
|
{isFileMessage && <FileMessage file={msg} />} |
||||
|
{isAudioMessage && <AudioMessage audio={msg} />} |
||||
|
{isTextMessage && <p className="text-xl">{messageContent}</p>} |
||||
|
|
||||
|
<div className="self-end mt-1"> |
||||
|
<div className="flex items-center gap-1"> |
||||
|
<p className="text-xs text-[#808080]">{messageTime}</p> |
||||
|
{isSeen && isUser && ( |
||||
|
<Image src={Seen} width={20} height={20} alt="Seen" /> |
||||
|
)} |
||||
|
{!isSeen && isUser && ( |
||||
|
<Image src={UnSeen} width={20} height={20} alt="Unseen" /> |
||||
|
)} |
||||
|
{isStringMessage && ( |
||||
|
<Image src={Pending} width={15} height={15} alt="Pending" /> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default Message; |
@ -0,0 +1,71 @@ |
|||||
|
import { useEffect, useRef, useMemo, useState } from "react"; |
||||
|
import Message from "./message"; // Ensure the path is correct and component is capitalized
|
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import { get } from "lodash"; |
||||
|
// import noMessages from "public/assets/images/Frame 1000005529.svg";
|
||||
|
// import Image from "next/image";
|
||||
|
|
||||
|
const MessageList = ({ messages }) => { |
||||
|
const chatBoxRef = useRef(null); |
||||
|
const { loadingMessage, getNextPage } = useWebSocket(); |
||||
|
|
||||
|
// if (messages) {
|
||||
|
// return (
|
||||
|
// <div className="w-full flex justify-center">
|
||||
|
// <Image className="" src={noMessages} />
|
||||
|
// </div>
|
||||
|
// );
|
||||
|
// }
|
||||
|
|
||||
|
const allMessages = useMemo(() => { |
||||
|
const combined = [...(loadingMessage || []), ...(messages || [])]; |
||||
|
return combined.reverse(); |
||||
|
}, [messages, loadingMessage]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (chatBoxRef.current && !(messages.length > 16)) { |
||||
|
// Scroll to the bottom whenever allMessages change
|
||||
|
chatBoxRef.current.scrollTop = chatBoxRef.current.scrollHeight; |
||||
|
} |
||||
|
}, [allMessages]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const handleScroll = () => { |
||||
|
if (chatBoxRef.current && chatBoxRef.current.scrollTop === 0) { |
||||
|
getNextPage(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
chatBoxRef.current?.addEventListener("scroll", handleScroll); |
||||
|
return () => |
||||
|
chatBoxRef.current?.removeEventListener("scroll", handleScroll); |
||||
|
}, [messages]); // Include dependencies
|
||||
|
return ( |
||||
|
<> |
||||
|
{/* Background Image */} |
||||
|
|
||||
|
{/* Chat Container */} |
||||
|
<div |
||||
|
ref={chatBoxRef} |
||||
|
className="flex flex-col h-full w-full absolute px-4 py-2 overflow-y-auto space-y-2 z-10" |
||||
|
role="log" // Optional: Improves accessibility
|
||||
|
aria-live="polite" // Optional: Announces new messages to screen readers
|
||||
|
style={{ |
||||
|
backgroundImage: "url('/assets/images/Frame 48098164.svg')", // Replace with your image path
|
||||
|
backgroundSize: "conrain", |
||||
|
backgroundPosition: "center", |
||||
|
}} |
||||
|
> |
||||
|
<div className="flex flex-col mb-[86px]"> |
||||
|
{allMessages.map((msg) => ( |
||||
|
<Message key={msg.id} msg={msg} /> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
{/* Optional: Loading Indicator */} |
||||
|
</div> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default MessageList; |
@ -0,0 +1,341 @@ |
|||||
|
import React, { |
||||
|
createContext, |
||||
|
ReactNode, |
||||
|
useContext, |
||||
|
useEffect, |
||||
|
useRef, |
||||
|
useState, |
||||
|
} from "react"; |
||||
|
import { useRouter } from "next/router"; |
||||
|
import { useMeQuery } from "@/data/user"; |
||||
|
|
||||
|
interface WebSocketData { |
||||
|
event: string; |
||||
|
token?: string; |
||||
|
account_type?: string; |
||||
|
page?: string; |
||||
|
per_page?: string; |
||||
|
message?: string; |
||||
|
} |
||||
|
|
||||
|
interface ChatRoom { |
||||
|
id: string; |
||||
|
title: string; |
||||
|
avatar: string; |
||||
|
last_message: string; |
||||
|
created_at: string; |
||||
|
} |
||||
|
|
||||
|
interface WebSocketContextType { |
||||
|
chatRooms: ChatRoom[]; |
||||
|
sendMessage: (message: string, id?: number) => void; |
||||
|
sendFile: (file: File, id?: number) => void; |
||||
|
connected: boolean; |
||||
|
messages: []; |
||||
|
loadingMessage: string[]; |
||||
|
roomInfo: {}; |
||||
|
} |
||||
|
|
||||
|
const WebSocketContext = createContext<WebSocketContextType | null>(null); |
||||
|
|
||||
|
export const useWebSocket = () => { |
||||
|
const context = useContext(WebSocketContext); |
||||
|
if (!context) { |
||||
|
throw new Error("useWebSocket must be used within a WebSocketProvider"); |
||||
|
} |
||||
|
return context; |
||||
|
}; |
||||
|
|
||||
|
interface WebSocketProviderProps { |
||||
|
children: ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ |
||||
|
children, |
||||
|
}) => { |
||||
|
const { user } = useMeQuery() |
||||
|
const [chatRooms, setChatRooms] = useState<ChatRoom[]>([]); |
||||
|
const [roomInfo, setRoomInfo] = useState<ChatRoom[]>([]); |
||||
|
const [messages, setMessages] = useState<[]>([]); |
||||
|
const socketRef = useRef<WebSocket | null>(null); |
||||
|
const [connected, setConnected] = useState(false); |
||||
|
const [loadingMessage, setLoadingMessage] = useState([]); |
||||
|
const [editingMessage, setEditingMessage] = useState({}); |
||||
|
const [roomID, setRommID] = useState<number>(); |
||||
|
const [nextPage, setNextPage] = useState(2); |
||||
|
|
||||
|
const router = useRouter(); |
||||
|
const userID = router.query.room; |
||||
|
const { |
||||
|
query: { slug }, |
||||
|
} = router; |
||||
|
|
||||
|
const getRooms = () => { |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "get_rooms", |
||||
|
account_type: "user", |
||||
|
page: "1", |
||||
|
per_page: "16", |
||||
|
} as WebSocketData) |
||||
|
); |
||||
|
}; |
||||
|
console.log(user); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
socketRef.current = new WebSocket("wss://mesbahi.nwhco.ir/chat/ws"); |
||||
|
setMessages([]); |
||||
|
|
||||
|
if (user?.token) { |
||||
|
socketRef.current.onopen = () => { |
||||
|
console.log("WebSocket Connected"); |
||||
|
|
||||
|
// Send connection request with user token
|
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "connect", |
||||
|
token: user?.token, // Pass user token
|
||||
|
}) |
||||
|
); |
||||
|
if (socketRef.current?.readyState === WebSocket.OPEN) { |
||||
|
socketRef.current.send( |
||||
|
JSON.stringify({ |
||||
|
event: "close_room", |
||||
|
page: "1", |
||||
|
per_page: "16", |
||||
|
}) |
||||
|
); |
||||
|
getRooms(); |
||||
|
if (userID && slug) { |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "enter_room", |
||||
|
merchant_id: `${userID}`, |
||||
|
user_id: "", |
||||
|
page: "1", |
||||
|
per_page: "16", |
||||
|
}) |
||||
|
); |
||||
|
} else if (userID) { |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "enter_room", |
||||
|
room_id: `${userID}`, |
||||
|
user_id: "", |
||||
|
page: "1", |
||||
|
per_page: "16", |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
setConnected(true); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
socketRef.current.onmessage = (event) => { |
||||
|
|
||||
|
const data = JSON.parse(event.data); |
||||
|
|
||||
|
if (data.event === "get_rooms") { |
||||
|
setChatRooms(data.rooms); |
||||
|
} |
||||
|
if (data.event === "new_message") { |
||||
|
// Update messages list when a new message is received
|
||||
|
setChatRooms((prevMessages) => [data.rooms, ...prevMessages]); |
||||
|
} |
||||
|
if (data.event === "update_room") { |
||||
|
if (data.current_page > 1) { |
||||
|
// Append new messages to the existing ones
|
||||
|
setMessages((prevMessages) => [ ...prevMessages , ...data.messages]); |
||||
|
setNextPage(data.current_page + 1); |
||||
|
console.log("next page received"); |
||||
|
} else { |
||||
|
// Replace the messages if it's the first page
|
||||
|
setMessages(data.messages); |
||||
|
} |
||||
|
setRommID(data.room_id); |
||||
|
setLoadingMessage([]); |
||||
|
getRooms(); |
||||
|
setRoomInfo(data.current_room); |
||||
|
} |
||||
|
|
||||
|
if (data.event === "update_room_list") { |
||||
|
// Add the new message to the messages state
|
||||
|
getRooms(); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
socketRef.current.onerror = (error) => { |
||||
|
console.error("WebSocket error:", error); |
||||
|
}; |
||||
|
|
||||
|
socketRef.current.onclose = () => { |
||||
|
console.log("WebSocket closed"); |
||||
|
setConnected(false); |
||||
|
}; |
||||
|
|
||||
|
return () => { |
||||
|
if (socketRef.current) { |
||||
|
socketRef.current.close(); |
||||
|
} |
||||
|
}; |
||||
|
}, [user, userID]); |
||||
|
|
||||
|
const getNextPage = () => { |
||||
|
setNextPage((prevPage) => { |
||||
|
const newPage = prevPage; |
||||
|
console.log("Fetching page:", newPage); |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "enter_room", |
||||
|
room_id: userID, |
||||
|
page: newPage.toString(), |
||||
|
per_page: "16", |
||||
|
}) |
||||
|
); |
||||
|
return newPage; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const sendMessage = (message: string, id?: number) => { |
||||
|
setLoadingMessage((prev) => [...prev, message]); |
||||
|
if (socketRef.current && connected) { |
||||
|
if (message.trim() === "") return; // Avoid sending empty messages
|
||||
|
|
||||
|
let newMessage = {}; |
||||
|
if (userID && slug && id) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: roomID, |
||||
|
content: message, |
||||
|
content_type: "text", // Modify this based on content type (text, image, etc.)
|
||||
|
product_reply: `${id}`, |
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} else if (userID && slug) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: roomID, |
||||
|
content: message, |
||||
|
content_type: "text", // Modify this based on content type (text, image, etc.)
|
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} else if (userID) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: userID, |
||||
|
content: message, |
||||
|
content_type: "text", // Modify this based on content type (text, image, etc.)
|
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
socketRef.current?.send(JSON.stringify(newMessage)); |
||||
|
} |
||||
|
}; |
||||
|
const sendFile = ( |
||||
|
file: { |
||||
|
url: string; |
||||
|
type: string; |
||||
|
size: string; |
||||
|
}, |
||||
|
id?: number |
||||
|
) => { |
||||
|
if (socketRef.current && connected) { |
||||
|
if (!file) return; // Avoid sending empty messages
|
||||
|
|
||||
|
let newMessage = {}; |
||||
|
if (userID && slug && id) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: roomID, |
||||
|
content: file.url, |
||||
|
content_type: file.type, // Modify this based on content type (text, image, etc.)
|
||||
|
content_size: file.size, |
||||
|
product_reply: `${id}`, |
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} else if (userID && slug) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: roomID, |
||||
|
content: file.url, |
||||
|
content_type: file.type, // Modify this based on content type (text, image, etc.)
|
||||
|
content_size: file.size, |
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} else if (userID) { |
||||
|
newMessage = { |
||||
|
account_type: "merchant", |
||||
|
event: "send_message_room", |
||||
|
room_id: userID, |
||||
|
content: file.url, |
||||
|
content_type: file.type, // Modify this based on content type (text, image, etc.)
|
||||
|
content_size: file.size, |
||||
|
// user_id: user?.id,
|
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
socketRef.current?.send(JSON.stringify(newMessage)); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const editMessage = (msg: { |
||||
|
id: string | number; |
||||
|
content: string; |
||||
|
type: string; |
||||
|
}) => { |
||||
|
if (socketRef.current && connected) { |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "update_message_room", |
||||
|
content: msg.content, |
||||
|
content_type: msg.type, |
||||
|
message_id: msg.id, |
||||
|
room_id: roomID, |
||||
|
account_type: "merchant", |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const deleteMessage = (msg: { id: string | number }) => { |
||||
|
if (socketRef.current && connected) { |
||||
|
socketRef.current?.send( |
||||
|
JSON.stringify({ |
||||
|
event: "delete_message_room", |
||||
|
message_id: msg.id, |
||||
|
room_id: roomID, |
||||
|
account_type: "merchant", |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<WebSocketContext.Provider |
||||
|
value={{ |
||||
|
chatRooms, |
||||
|
sendMessage, |
||||
|
connected, |
||||
|
messages, |
||||
|
loadingMessage, |
||||
|
roomInfo, |
||||
|
sendFile, |
||||
|
setLoadingMessage, |
||||
|
editingMessage, |
||||
|
setEditingMessage, |
||||
|
editMessage, |
||||
|
deleteMessage, |
||||
|
getNextPage, |
||||
|
}} |
||||
|
> |
||||
|
{children} |
||||
|
</WebSocketContext.Provider> |
||||
|
); |
||||
|
}; |
@ -0,0 +1,46 @@ |
|||||
|
import ContactInfo from "@/components/chat/contact-info"; |
||||
|
import MessageList from "@/components/chat/messages-list"; |
||||
|
import Input from "@/components/ui/input"; |
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import noMessages from "public/image/Frame 1000005529.svg"; |
||||
|
import { useRouter } from "next/router"; |
||||
|
import Image from "next/image"; |
||||
|
export default function ChatBox({ product , searchValue }) { |
||||
|
const { messages } = useWebSocket(); |
||||
|
const router = useRouter(); |
||||
|
const userID = router.query.room; |
||||
|
// Combine and reverse messages using useMemo for optimization
|
||||
|
|
||||
|
// Initialize WebSocket connection when the component mounts
|
||||
|
|
||||
|
// Handle sending a message
|
||||
|
|
||||
|
// If there are no messages, show the no-message image
|
||||
|
if (!userID) { |
||||
|
return ( |
||||
|
<div className="w-full flex justify-center"> |
||||
|
<Image className="" src={noMessages} /> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="relative flex flex-col w-full h-[calc(100%+65px)] bg-white shadow-md border border-[#D9D9D9]"> |
||||
|
<ContactInfo /> |
||||
|
|
||||
|
<div className="relative flex h-full"> |
||||
|
{/* Chat Content */} |
||||
|
<MessageList messages={messages} /> |
||||
|
|
||||
|
{/* Input Bar */} |
||||
|
<Input product={product} /> |
||||
|
{/* <button |
||||
|
onClick={handleSendMessage} |
||||
|
className="bg-blue-500 text-white p-2 rounded" |
||||
|
> |
||||
|
Send |
||||
|
</button> */} |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
@ -0,0 +1,59 @@ |
|||||
|
import { FC } from 'react'; |
||||
|
import { IoLocationSharp, IoMail, IoCallSharp } from 'react-icons/io5'; |
||||
|
import Link from '@/components/ui/link'; |
||||
|
import { useTranslation } from 'next-i18next'; |
||||
|
const mapImage = '/assets/images/map-image.jpg'; |
||||
|
const data = [ |
||||
|
{ |
||||
|
id: 1, |
||||
|
slug: '/', |
||||
|
icon: <IoLocationSharp />, |
||||
|
name: 'text-address', |
||||
|
description: 'text-address-details', |
||||
|
}, |
||||
|
{ |
||||
|
id: 2, |
||||
|
slug: '/', |
||||
|
icon: <IoMail />, |
||||
|
name: 'text-email', |
||||
|
description: 'text-email-details', |
||||
|
}, |
||||
|
{ |
||||
|
id: 3, |
||||
|
slug: '/', |
||||
|
icon: <IoCallSharp />, |
||||
|
name: 'text-phone', |
||||
|
description: 'text-phone-details', |
||||
|
}, |
||||
|
]; |
||||
|
interface Props { |
||||
|
image?: HTMLImageElement; |
||||
|
} |
||||
|
const ContactInfoBlock: FC<Props> = () => { |
||||
|
const { t } = useTranslation('common'); |
||||
|
return ( |
||||
|
<div className="mb-6 lg:border lg:rounded-md border-gray-300 lg:p-7"> |
||||
|
<h4 className="text-2xl md:text-lg font-bold text-heading pb-7 md:pb-10 lg:pb-6 -mt-1"> |
||||
|
{t('text-find-us-here')} |
||||
|
</h4> |
||||
|
{data?.map((item: any) => ( |
||||
|
<div key={`contact--key${item.id}`} className="flex pb-7"> |
||||
|
<div className="flex flex-shrink-0 justify-center items-center p-1.5 border rounded-md border-gray-300 w-10 h-10"> |
||||
|
{item.icon} |
||||
|
</div> |
||||
|
<div className="flex flex-col ltr:pl-3 rtl:pr-3 ltr:2xl:pl-4 rtl:2xl:pr-4"> |
||||
|
<h5 className="text-sm font-bold text-heading"> |
||||
|
{t(`${item.name}`)} |
||||
|
</h5> |
||||
|
<Link href={item.slug} className="text-sm mt-0"> |
||||
|
{t(`${item.description}`)} |
||||
|
</Link> |
||||
|
</div> |
||||
|
</div> |
||||
|
))} |
||||
|
<img src={mapImage} alt={t('text-map')} className="rounded-md" /> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default ContactInfoBlock; |
@ -0,0 +1,126 @@ |
|||||
|
import Image from "next/image"; |
||||
|
import Badge from "@/components/ui/badge/badge"; |
||||
|
import Search from "public/image/search.svg"; |
||||
|
import Bars from "public/image/bars.svg"; |
||||
|
import { useEffect, useState } from "react"; |
||||
|
import { useRouter } from "next/router"; |
||||
|
import { useWebSocket } from "@/contexts/WebSocket.context"; |
||||
|
import { useMeQuery } from "@/data/user"; |
||||
|
|
||||
|
export default function Conversations() { |
||||
|
const router = useRouter(); |
||||
|
const { chatRooms, connected } = useWebSocket(); |
||||
|
const {data : user} = useMeQuery() |
||||
|
// States for chat rooms and search
|
||||
|
const [filteredChatRooms, setFilteredChatRooms] = useState(chatRooms || []); |
||||
|
const [searchValue, setSearchValue] = useState(""); |
||||
|
|
||||
|
// Date formatting function
|
||||
|
const formatDate = (dateString) => { |
||||
|
const date = new Date(dateString); |
||||
|
const options = { day: "numeric", month: "long" }; |
||||
|
return date.toLocaleDateString("en-GB", options); // Formats date to '27 November'
|
||||
|
}; |
||||
|
|
||||
|
// Sync WebSocket chatRooms with local state
|
||||
|
useEffect(() => { |
||||
|
if (user?.token && connected) { |
||||
|
setFilteredChatRooms(chatRooms || []); |
||||
|
} |
||||
|
}, [user, connected, chatRooms]); |
||||
|
|
||||
|
// Handle search
|
||||
|
useEffect(() => { |
||||
|
if (searchValue.trim()) { |
||||
|
const filteredData = chatRooms.filter((item) => |
||||
|
item.merchant.title.toLowerCase().includes(searchValue.toLowerCase()) |
||||
|
); |
||||
|
setFilteredChatRooms(filteredData); |
||||
|
} else { |
||||
|
setFilteredChatRooms(chatRooms); // Reset to full data when search is cleared
|
||||
|
} |
||||
|
}, [searchValue, chatRooms]); |
||||
|
|
||||
|
// Handle room click
|
||||
|
const handleRoomClick = (roomId, unreadCount) => { |
||||
|
const updatedRooms = filteredChatRooms.map((room) => |
||||
|
room.id === roomId ? { ...room, unread_count: 0 } : room |
||||
|
); |
||||
|
setFilteredChatRooms(updatedRooms); |
||||
|
router.push({ |
||||
|
pathname: router.pathname, |
||||
|
query: { room: roomId }, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div className="border border-[#D9D9D9] h-[calc(100%+65px)]"> |
||||
|
<div className="bg-white shadow rounded-md h-full w-[450px] mx-auto"> |
||||
|
{/* Header Section */} |
||||
|
<div className="border-b border-[#D9D9D9] justify-end flex flex-row-reverse h-24 items-center p-4"> |
||||
|
<div className="w-full flex content-center border border-[#EDEDED] p-1 gap-2 focus-within:border-black"> |
||||
|
<label className="pt-1"> |
||||
|
<Image className="m-auto" src={Search} alt="Search Icon" /> |
||||
|
</label> |
||||
|
<input |
||||
|
value={searchValue} |
||||
|
onChange={(e) => setSearchValue(e.target.value)} |
||||
|
className="w-full focus:outline-none" |
||||
|
aria-label="Search conversations" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="mr-4"> |
||||
|
<Image src={Bars} alt="Menu Icon" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Messages List */} |
||||
|
<div className="w-full"> |
||||
|
{filteredChatRooms?.length > 0 ? ( |
||||
|
filteredChatRooms.map((msg) => { |
||||
|
const unreadCounts = msg?.unread_count ?? 0; |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
key={msg?.id} |
||||
|
className="grid gap-3 grid-cols-[auto,1fr] py-6 w-full items-center p-3 border-b last:border-b-0 hover:bg-[#F6F6F6] cursor-pointer" |
||||
|
onClick={() => handleRoomClick(msg?.id, unreadCounts)} |
||||
|
> |
||||
|
{/* Profile Image */} |
||||
|
<img |
||||
|
src={msg?.merchant?.logo || "/default-logo.png"} |
||||
|
alt={`${msg?.merchant?.title} logo`} |
||||
|
className="w-16 h-16 rounded-full object-cover max-w-none border" |
||||
|
/> |
||||
|
{/* Message Info */} |
||||
|
<div> |
||||
|
<div className="flex justify-between"> |
||||
|
<p className="font-semibold mb-1"> |
||||
|
{msg?.merchant?.title || "Unknown Merchant"} |
||||
|
</p> |
||||
|
<div className="flex gap-2"> |
||||
|
<p className="text-xs">{formatDate(msg?.created_at)}</p> |
||||
|
{unreadCounts > 0 && ( |
||||
|
<Badge className="bg-[#48C522] text-white font-semibold text-xs w-4 h-4"> |
||||
|
{unreadCounts} |
||||
|
</Badge> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="flex justify-between"> |
||||
|
<p className="text-xs w-80 text-zinc-400 overflow-hidden whitespace-nowrap text-ellipsis"> |
||||
|
{msg?.last_message?.content || "No message content"} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}) |
||||
|
) : ( |
||||
|
<p className="text-center text-zinc-400 py-4">No conversations found</p> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
@ -0,0 +1,32 @@ |
|||||
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations"; |
||||
|
import Layout from '@/components/layouts/admin'; |
||||
|
import { GetStaticProps } from "next"; |
||||
|
import Conversations from "./converstions"; |
||||
|
import ChatBox from "./chat-box"; |
||||
|
|
||||
|
export default function Chat() { |
||||
|
return ( |
||||
|
<div className="flex h-full -m-8"> |
||||
|
<Conversations /> |
||||
|
<ChatBox/> |
||||
|
{/* <ProductInfo/> */} |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Chat.Layout = Layout; |
||||
|
|
||||
|
export const getStaticProps: GetStaticProps = async ({ locale }) => { |
||||
|
return { |
||||
|
props: { |
||||
|
...(await serverSideTranslations(locale!, [ |
||||
|
"common", |
||||
|
"forms", |
||||
|
"menu", |
||||
|
"faq", |
||||
|
"footer", |
||||
|
])), |
||||
|
}, |
||||
|
}; |
||||
|
}; |
||||
|
// py-16 lg:py-20 px-0 max-w-5xl mx-auto space-y-4
|
Write
Preview
Loading…
Cancel
Save
Reference in new issue