Browse Source

feat: add new SVG icons and update chat components with loading states

master
sina_sajjadi 2 weeks ago
parent
commit
751d4cf092
  1. 3
      package.json
  2. 49
      public/image/Frame 1000005529.svg
  3. 3
      public/image/State=Send.svg
  4. 3
      public/image/Vector.svg
  5. 3
      public/image/VectorWhite.svg
  6. 3
      public/image/bars.svg
  7. BIN
      public/image/clock-fast-forward.png
  8. 3
      public/image/copy-06.svg
  9. 3
      public/image/edit-04.svg
  10. 3
      public/image/quill_checkmark-double.svg
  11. 3
      public/image/search.svg
  12. 3
      public/image/trash-03.svg
  13. 99
      src/components/chat/audio-message.tsx
  14. 305
      src/components/chat/chat-input.tsx
  15. 32
      src/components/chat/contact-info.tsx
  16. 79
      src/components/chat/contex-menu.tsx
  17. 173
      src/components/chat/file-message.tsx
  18. 50
      src/components/chat/image-message.tsx
  19. 267
      src/components/chat/message.tsx
  20. 71
      src/components/chat/messages-list.tsx
  21. 1
      src/components/layouts/admin/index.tsx
  22. 135
      src/components/layouts/topbar/message-bar.tsx
  23. 341
      src/contexts/WebSocket.context.tsx
  24. 33
      src/pages/_app.tsx
  25. 46
      src/pages/chat/chat-box.tsx
  26. 59
      src/pages/chat/contact-info.tsx
  27. 126
      src/pages/chat/converstions.tsx
  28. 32
      src/pages/chat/index.tsx
  29. 3
      src/pages/subscriptions/active-section.tsx
  30. 4
      src/pages/subscriptions/history-section.tsx
  31. 12
      src/pages/subscriptions/plans-section.tsx

3
package.json

@ -23,6 +23,7 @@
"cookie": "0.6.0",
"date-fns": "^4.1.0",
"dayjs": "1.11.10",
"emoji-picker-react": "^4.12.0",
"framer-motion": "10.16.4",
"i18next": "23.6.0",
"jotai": "2.5.1",
@ -41,6 +42,7 @@
"rc-table": "7.22.2",
"react": "18.2.0",
"react-apexcharts": "1.4.1",
"react-audio-visualize": "^1.2.0",
"react-content-loader": "6.2.1",
"react-countdown": "2.3.5",
"react-datepicker": "4.21.0",
@ -50,6 +52,7 @@
"react-i18next": "13.3.1",
"react-icons": "^5.4.0",
"react-laag": "2.0.5",
"react-media-recorder": "^1.7.1",
"react-phone-input-2": "2.15.1",
"react-query": "3.39.3",
"react-quill": "2.0.0",

49
public/image/Frame 1000005529.svg
File diff suppressed because it is too large
View File

3
public/image/State=Send.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="M5.59961 12.75L9.34961 16.5L18.3496 7.5" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

3
public/image/Vector.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>

3
public/image/VectorWhite.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>

3
public/image/bars.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>

BIN
public/image/clock-fast-forward.png

After

Width: 24  |  Height: 24  |  Size: 650 B

3
public/image/copy-06.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="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>

3
public/image/edit-04.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>

3
public/image/quill_checkmark-double.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>

3
public/image/search.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>

3
public/image/trash-03.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>

99
src/components/chat/audio-message.tsx

@ -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;

305
src/components/chat/chat-input.tsx

@ -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;

32
src/components/chat/contact-info.tsx

@ -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;

79
src/components/chat/contex-menu.tsx

@ -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;

173
src/components/chat/file-message.tsx

@ -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;

50
src/components/chat/image-message.tsx

@ -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;

267
src/components/chat/message.tsx

@ -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;

71
src/components/chat/messages-list.tsx

@ -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;

1
src/components/layouts/admin/index.tsx

@ -155,7 +155,6 @@ const AdminLayout: React.FC<{ children?: React.ReactNode }> = ({
)}
>
<div className="h-full p-5 md:p-8">{children}</div>
<Footer />
</main>
</div>
</div>

135
src/components/layouts/topbar/message-bar.tsx

@ -96,142 +96,7 @@ const MessageBar = ({ user }: IProps) => {
title={t('text-messages')}
/> */}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
as="div"
className="absolute top-16 z-30 w-80 rounded-lg border border-gray-200 bg-white shadow-box end-2 origin-top-end focus:outline-none sm:top-12 sm:mt-0.5 sm:end-0 lg:top-14 lg:mt-0"
>
<Menu.Item>
<>
<div className="flex items-center justify-between rounded-tl-lg rounded-tr-lg border-b border-gray-200/80 px-5 py-4 font-medium">
<span>{t('text-messages')}</span>
{activeStatus?.unseen ? (
<span
className="block cursor-pointer text-sm font-medium text-accent hover:text-heading"
onClick={markAllAsRead}
>
{t('text-mark-all-read')}
</span>
) : (
''
)}
</div>
<div className="py-0">
{conversations?.length ? (
conversations?.map((item: any) => {
const routes = permission
? Routes?.message?.details(item?.id)
: Routes?.shopMessage?.details(item?.id);
const seenMessage = (unseen: boolean) => {
if (unseen) {
createSeenMessage({
id: item?.id,
});
}
};
return (
<div
className="group cursor-pointer border-b border-dashed border-gray-200 last:border-b-0"
key={item?.id}
>
<div
className={cn(
'flex gap-2 rounded-md py-3.5 px-5 text-sm font-semibold capitalize transition duration-200 hover:text-accent group-hover:bg-gray-100/70'
)}
onClick={() => {
router.push(`${routes}`);
seenMessage(Boolean(item?.unseen));
}}
>
<div className="flex w-full items-center gap-x-3">
<div className="relative h-8 w-8 shrink-0 grow-0 basis-auto rounded-full 2xl:h-9 2xl:w-9">
{item?.unseen ? (
<span className="absolute top-0 right-0 z-10 h-2.5 w-2.5 rounded-full border border-white bg-blue-700"></span>
) : (
''
)}
{!isEmpty(item?.shop?.logo?.thumbnail) ? (
<Image
// @ts-ignore
src={item?.shop?.logo?.thumbnail}
alt={String(item?.shop?.name)}
fill
sizes="(max-width: 768px) 100vw"
className="product-image rounded-full object-contain"
/>
) : (
<MessageAvatarPlaceholderIcon
className="text-[2rem] 2xl:text-[2.5rem]"
color="#DDDDDD"
/>
)}
</div>
<div className="block w-10/12">
<div className="flex items-center justify-between">
{isEmpty(item?.latest_message?.body) ? (
<h2 className="mr-1 w-[70%] truncate text-sm font-semibold">
{item?.shop?.name}
</h2>
) : (
<h2 className="mr-1 w-[70%] truncate text-sm font-semibold">
{item?.shop?.name}
</h2>
)}
{item?.latest_message?.created_at ? (
<p className="truncate text-xs font-normal text-[#686D73]">
{dayjs().to(
dayjs.utc(
item?.latest_message?.created_at
)
)}
</p>
) : (
''
)}
</div>
{!isEmpty(item?.latest_message?.body) ? (
<p className="mt-1 truncate text-xs font-normal text-[#64748B]">
{item?.latest_message?.body}
</p>
) : (
''
)}
</div>
</div>
</div>
</div>
);
})
) : (
<p className="mb-2 pt-5 pb-4 text-center text-sm font-medium text-gray-500">
{t('no-message-found')}
</p>
)}
</div>
<Link
href={
permission
? Routes?.notifyLogs?.list
: `${Routes?.ownerDashboardNotifyLogs}/user/${user?.id}`
}
className="block border-t border-gray-200/80 p-3 text-center text-sm font-medium text-accent hover:text-accent-hover"
>
{t('text-see-all-notification')}
</Link>
</>
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</>
);

341
src/contexts/WebSocket.context.tsx

@ -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>
);
};

33
src/pages/_app.tsx

@ -20,6 +20,7 @@ import type { NextPageWithLayout } from '@/types';
import { useRouter } from 'next/router';
import PrivateRoute from '@/utils/private-route';
import { Config } from '@/config';
import { WebSocketProvider } from '@/contexts/WebSocket.context';
const Noop: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<>{children}</>
);
@ -51,24 +52,26 @@ const CustomApp = ({ Component, pageProps }: AppPropsWithLayout) => {
<AppSettings>
<UIProvider>
<ModalProvider>
<>
<CartProvider>
<DefaultSeo />
{authProps ? (
<PrivateRoute authProps={authProps}>
<WebSocketProvider>
<>
<CartProvider>
<DefaultSeo />
{authProps ? (
<PrivateRoute authProps={authProps}>
<Layout {...pageProps}>
<Component {...pageProps} />
</Layout>
</PrivateRoute>
) : (
<Layout {...pageProps}>
<Component {...pageProps} />
</Layout>
</PrivateRoute>
) : (
<Layout {...pageProps}>
<Component {...pageProps} />
</Layout>
)}
<ToastContainer autoClose={2000} theme="colored" />
<ManagedModal />
</CartProvider>
</>
)}
<ToastContainer autoClose={2000} theme="colored" />
<ManagedModal />
</CartProvider>
</>
</WebSocketProvider>
</ModalProvider>
</UIProvider>
</AppSettings>

46
src/pages/chat/chat-box.tsx

@ -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>
);
}

59
src/pages/chat/contact-info.tsx

@ -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;

126
src/pages/chat/converstions.tsx

@ -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>
);
}

32
src/pages/chat/index.tsx

@ -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

3
src/pages/subscriptions/active-section.tsx

@ -4,6 +4,7 @@ import { FaStar } from 'react-icons/fa';
import Image from 'next/image';
import background from '../../../public/image/Frame 1000005757.webp';
import { RiErrorWarningFill } from 'react-icons/ri';
import Loader from '@/components/ui/loader/loader';
const ActiveSubscriptionSection: React.FC = () => {
const { data: activeData, isLoading: isActiveLoading } =
@ -23,7 +24,7 @@ const ActiveSubscriptionSection: React.FC = () => {
};
if (isActiveLoading) {
return <div>Loading...</div>;
return <Loader showText={false}/>;
}
if (activeData?.status === 'no_subscription') {

4
src/pages/subscriptions/history-section.tsx

@ -4,6 +4,7 @@ import {
useGetAllSubscriptions,
useGetSubscriptionsHistory,
} from '@/data/subscription';
import Loader from '@/components/ui/loader/loader';
const HistorySection: React.FC = () => {
const { data: historyData, isLoading: isHistoryLoading } =
@ -31,7 +32,8 @@ const HistorySection: React.FC = () => {
return priceNumber.toFixed(0); // Removes decimals
};
if (isHistoryLoading) {
return <p>Loading...</p>;
return <Loader showText={false}/>;
}
console.log(historyData);

12
src/pages/subscriptions/plans-section.tsx

@ -10,6 +10,7 @@ import payPal from '../../../public/image/payments/🦆 icon _PayPal_.svg';
import Stripe from '../../../public/image/payments/🦆 icon _Stripe_.svg';
import Image from 'next/image';
import { toast } from 'react-toastify'; // Import toast
import Loader from '@/components/ui/loader/loader';
interface Subscription {
id: number;
@ -101,7 +102,8 @@ const PlansSection: React.FC = () => {
};
if (isAllLoading) {
return <div>Loading...</div>;
return <Loader showText={false}/>;
}
return (
@ -155,7 +157,7 @@ const PlansSection: React.FC = () => {
</h1>
<div className="border p-8 grid grid-cols-1 gap-8 text-[#666666] text-sm rounded-lg lg:grid-cols-2">
<div className="flex items-start gap-2">
<FaRegCheckCircle color="#1DCE1D" size={15} />
<FaRegCheckCircle className='w-6' color="#1DCE1D" size={15} />
<p>
Customizable Storefront: Merchants can personalize their
storefront with custom branding, logos, and banners to create a
@ -163,7 +165,7 @@ const PlansSection: React.FC = () => {
</p>
</div>
<div className="flex items-start gap-2">
<FaRegCheckCircle color="#1DCE1D" size={15} />
<FaRegCheckCircle className='w-6' color="#1DCE1D" size={15} />
<p>
Advanced Analytics Dashboard: Access detailed insights into sales,
traffic, and customer behavior to optimize listings and marketing
@ -171,14 +173,14 @@ const PlansSection: React.FC = () => {
</p>
</div>
<div className="flex items-start gap-2">
<FaRegCheckCircle color="#1DCE1D" size={15} />
<FaRegCheckCircle className='w-6' color="#1DCE1D" size={15} />
<p>
Bulk Listing Management: Easily upload and manage multiple
gemstone listings at once with bulk editing features.
</p>
</div>
<div className="flex items-start gap-2">
<FaRegCheckCircle color="#1DCE1D" size={15} />
<FaRegCheckCircle className='w-6' color="#1DCE1D" size={15} />
<p>
Real-Time Inventory Tracking: Merchants can track stock levels in
real-time, ensuring they never over-sell or run out of inventory.

Loading…
Cancel
Save