classify web
This commit is contained in:
139
components/PagesComponent/Chat/BlockedUsersMenu.jsx
Normal file
139
components/PagesComponent/Chat/BlockedUsersMenu.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { t } from "@/utils";
|
||||
import { RiUserForbidLine } from "react-icons/ri";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBlockedUsers, unBlockUserApi } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const BlockedUsersMenu = ({ setSelectedChatDetails }) => {
|
||||
const [blockedUsersList, setBlockedUsersList] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unblockingId, setUnblockingId] = useState("");
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const fetchBlockedUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getBlockedUsers.blockedUsers();
|
||||
const { data } = response;
|
||||
setBlockedUsersList(data?.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching blocked users:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
fetchBlockedUsers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnblock = async (userId, e) => {
|
||||
e.stopPropagation();
|
||||
setUnblockingId(userId);
|
||||
try {
|
||||
const response = await unBlockUserApi.unBlockUser({
|
||||
blocked_user_id: userId,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
// Refresh the blocked users list after successful unblock
|
||||
setBlockedUsersList((prevList) =>
|
||||
prevList.filter((user) => user.id !== userId)
|
||||
);
|
||||
setSelectedChatDetails((prev) => ({
|
||||
...prev,
|
||||
user_blocked: false,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error unblocking user:", error);
|
||||
} finally {
|
||||
setUnblockingId("");
|
||||
}
|
||||
};
|
||||
|
||||
const BlockedUserSkeleton = () => (
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none">
|
||||
<RiUserForbidLine size={22} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isRTL ? "start" : "end"} className="w-72">
|
||||
<DropdownMenuLabel>{t("blockedUsers")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }, (_, index) => (
|
||||
<BlockedUserSkeleton key={index} />
|
||||
))
|
||||
) : blockedUsersList && blockedUsersList.length > 0 ? (
|
||||
<DropdownMenuGroup>
|
||||
{blockedUsersList.map((user) => (
|
||||
<DropdownMenuItem
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-10 w-10 flex-shrink-0 rounded-full overflow-hidden bg-gray-200 relative">
|
||||
<CustomImage
|
||||
src={user?.profile}
|
||||
alt={user.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">{user.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleUnblock(user?.id, e)}
|
||||
disabled={unblockingId === user?.id}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
unblockingId === user?.id
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-primary hover:bg-primary/80"
|
||||
} text-white rounded-md flex-shrink-0 ml-2`}
|
||||
>
|
||||
{t("unblock")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{t("noBlockedUsers")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
export default BlockedUsersMenu;
|
||||
173
components/PagesComponent/Chat/Chat.jsx
Normal file
173
components/PagesComponent/Chat/Chat.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
import SelectedChatHeader from "./SelectedChatHeader";
|
||||
import ChatList from "./ChatList";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import NoChatFound from "./NoChatFound";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getCurrentLangCode } from "@/redux/reducer/languageSlice";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { chatListApi } from "@/utils/api";
|
||||
import { useNavigate } from "@/components/Common/useNavigate";
|
||||
|
||||
const Chat = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const activeTab = searchParams.get("activeTab") || "selling";
|
||||
const chatId = Number(searchParams.get("chatid")) || "";
|
||||
const [selectedChatDetails, setSelectedChatDetails] = useState();
|
||||
const langCode = useSelector(getCurrentLangCode);
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const [IsLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [buyer, setBuyer] = useState({
|
||||
BuyerChatList: [],
|
||||
CurrentBuyerPage: 1,
|
||||
HasMoreBuyer: false,
|
||||
});
|
||||
|
||||
const [seller, setSeller] = useState({
|
||||
SellerChatList: [],
|
||||
CurrentSellerPage: 1,
|
||||
HasMoreSeller: false,
|
||||
});
|
||||
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1200px)");
|
||||
|
||||
const fetchSellerChatList = async (page = 1) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await chatListApi.chatList({ type: "seller", page });
|
||||
if (res?.data?.error === false) {
|
||||
const data = res?.data?.data?.data;
|
||||
const currentPage = res?.data?.data?.current_page;
|
||||
const lastPage = res?.data?.data?.last_page;
|
||||
|
||||
setSeller((prev) => ({
|
||||
...prev,
|
||||
SellerChatList: page === 1 ? data : [...prev.SellerChatList, ...data],
|
||||
CurrentSellerPage: currentPage,
|
||||
HasMoreSeller: currentPage < lastPage,
|
||||
}));
|
||||
} else {
|
||||
console.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching seller chat list:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBuyerChatList = async (page = 1) => {
|
||||
if (page === 1) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await chatListApi.chatList({ type: "buyer", page });
|
||||
if (res?.data?.error === false) {
|
||||
const data = res?.data?.data?.data;
|
||||
const currentPage = res?.data?.data?.current_page;
|
||||
const lastPage = res?.data?.data?.last_page;
|
||||
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: page === 1 ? data : [...prev.BuyerChatList, ...data],
|
||||
CurrentBuyerPage: currentPage,
|
||||
HasMoreBuyer: currentPage < lastPage,
|
||||
}));
|
||||
} else {
|
||||
console.log(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching buyer chat list:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
activeTab === "selling" ? fetchSellerChatList() : fetchBuyerChatList();
|
||||
}, [activeTab, langCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId && activeTab === "selling" && seller.SellerChatList.length > 0) {
|
||||
setSelectedChatDetails(
|
||||
seller.SellerChatList.find((chat) => chat.id === chatId)
|
||||
);
|
||||
} else if (
|
||||
chatId &&
|
||||
activeTab === "buying" &&
|
||||
buyer.BuyerChatList.length > 0
|
||||
) {
|
||||
setSelectedChatDetails(
|
||||
buyer.BuyerChatList.find((chat) => chat.id === chatId)
|
||||
);
|
||||
} else if (!chatId) {
|
||||
setSelectedChatDetails("");
|
||||
}
|
||||
}, [chatId, activeTab, seller.SellerChatList, buyer.BuyerChatList, langCode]);
|
||||
|
||||
const handleBack = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("chatid");
|
||||
navigate(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12">
|
||||
<div className="col-span-4">
|
||||
{(isLargeScreen || !chatId || IsLoading) && (
|
||||
<ChatList
|
||||
chatId={chatId}
|
||||
activeTab={activeTab}
|
||||
buyer={buyer}
|
||||
setBuyer={setBuyer}
|
||||
langCode={langCode}
|
||||
isLargeScreen={isLargeScreen}
|
||||
seller={seller}
|
||||
setSeller={setSeller}
|
||||
IsLoading={IsLoading}
|
||||
fetchSellerChatList={fetchSellerChatList}
|
||||
fetchBuyerChatList={fetchBuyerChatList}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(isLargeScreen || chatId) && (
|
||||
<div className="col-span-8">
|
||||
{selectedChatDetails?.id ? (
|
||||
<div className="ltr:xl:border-l rtl:lg:border-r h-[65vh] lg:h-[800px] flex flex-col">
|
||||
<SelectedChatHeader
|
||||
selectedChat={selectedChatDetails}
|
||||
isSelling={activeTab === "selling"}
|
||||
setSelectedChat={setSelectedChatDetails}
|
||||
handleBack={handleBack}
|
||||
isLargeScreen={isLargeScreen}
|
||||
/>
|
||||
<ChatMessages
|
||||
selectedChatDetails={selectedChatDetails}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
isSelling={activeTab === "selling"}
|
||||
setBuyer={setBuyer}
|
||||
chatId={chatId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ltr:xl:border-l rtl:xl:border-r h-[60vh] lg:h-[800px] flex items-center justify-center">
|
||||
<NoChatFound
|
||||
isLargeScreen={isLargeScreen}
|
||||
handleBack={handleBack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
119
components/PagesComponent/Chat/ChatList.jsx
Normal file
119
components/PagesComponent/Chat/ChatList.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { t } from "@/utils";
|
||||
import ChatListCard from "./ChatListCard";
|
||||
import ChatListCardSkeleton from "./ChatListCardSkeleton";
|
||||
import BlockedUsersMenu from "./BlockedUsersMenu";
|
||||
import NoChatListFound from "./NoChatListFound";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
|
||||
const ChatList = ({
|
||||
chatId,
|
||||
activeTab,
|
||||
buyer,
|
||||
setBuyer,
|
||||
isLargeScreen,
|
||||
seller,
|
||||
setSeller,
|
||||
IsLoading,
|
||||
fetchSellerChatList,
|
||||
fetchBuyerChatList,
|
||||
setSelectedChatDetails
|
||||
}) => {
|
||||
const handleChatTabClick = (chat, isSelling) => {
|
||||
if (isSelling) {
|
||||
setSeller((prev) => ({
|
||||
...prev,
|
||||
SellerChatList: prev.SellerChatList.map((item) =>
|
||||
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: prev.BuyerChatList.map((item) =>
|
||||
item.id === chat.id ? { ...item, unread_chat_count: 0 } : item
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[60vh] max-h-[800px] flex flex-col lg:h-full">
|
||||
{isLargeScreen && (
|
||||
<div className="p-4 flex items-center gap-1 justify-between border-b">
|
||||
<h4 className="font-medium text-xl">{t("chat")}</h4>
|
||||
{/* Blocked Users Menu Component */}
|
||||
<BlockedUsersMenu setSelectedChatDetails={setSelectedChatDetails} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<CustomLink
|
||||
href={`/chat?activeTab=selling`}
|
||||
className={`py-4 flex-1 text-center border-b ${activeTab === "selling" ? "border-primary" : ""
|
||||
}`}
|
||||
scroll={false}
|
||||
>
|
||||
{t("selling")}
|
||||
</CustomLink>
|
||||
<CustomLink
|
||||
href={`/chat?activeTab=buying`}
|
||||
className={`py-4 flex-1 text-center border-b ${activeTab === "buying" ? "border-primary" : ""
|
||||
}`}
|
||||
scroll={false}
|
||||
>
|
||||
{t("buying")}
|
||||
</CustomLink>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto" id="chatList">
|
||||
<InfiniteScroll
|
||||
dataLength={
|
||||
activeTab === "buying"
|
||||
? buyer.BuyerChatList?.length
|
||||
: seller.SellerChatList?.length
|
||||
}
|
||||
next={() => {
|
||||
activeTab === "buying"
|
||||
? fetchBuyerChatList(buyer.CurrentBuyerPage + 1)
|
||||
: fetchSellerChatList(seller.CurrentSellerPage + 1);
|
||||
}}
|
||||
hasMore={
|
||||
activeTab === "buying" ? buyer.HasMoreBuyer : seller.HasMoreSeller
|
||||
}
|
||||
loader={Array.from({ length: 3 }, (_, index) => (
|
||||
<ChatListCardSkeleton key={index} />
|
||||
))}
|
||||
scrollableTarget="chatList"
|
||||
>
|
||||
{IsLoading
|
||||
? Array.from({ length: 8 }, (_, index) => (
|
||||
<ChatListCardSkeleton key={index} />
|
||||
))
|
||||
: (() => {
|
||||
const chatList =
|
||||
activeTab === "selling"
|
||||
? seller.SellerChatList
|
||||
: buyer.BuyerChatList;
|
||||
return chatList.length > 0 ? (
|
||||
chatList.map((chat, index) => (
|
||||
<ChatListCard
|
||||
key={Number(chat.id) || index}
|
||||
chat={chat}
|
||||
isActive={chat?.id === chatId}
|
||||
isSelling={activeTab === "selling"}
|
||||
handleChatTabClick={handleChatTabClick}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<NoChatListFound />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatList;
|
||||
62
components/PagesComponent/Chat/ChatListCard.jsx
Normal file
62
components/PagesComponent/Chat/ChatListCard.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { formatTime } from "@/utils";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const ChatListCard = ({ chat, isSelling, isActive, handleChatTabClick }) => {
|
||||
const user = isSelling ? chat?.buyer : chat?.seller;
|
||||
const isUnread = chat?.unread_chat_count > 0;
|
||||
|
||||
return (
|
||||
<CustomLink
|
||||
scroll={false}
|
||||
href={`/chat?activeTab=${isSelling ? "selling" : "buying"}&chatid=${
|
||||
chat?.id
|
||||
}`}
|
||||
onClick={() => handleChatTabClick(chat, isSelling)}
|
||||
className={`py-3 px-4 border-b flex items-center gap-4 cursor-pointer ${
|
||||
isActive ? "bg-primary text-white" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<CustomImage
|
||||
src={user?.profile}
|
||||
alt="User avatar"
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-[56px] h-auto aspect-square object-cover rounded-full"
|
||||
/>
|
||||
|
||||
<CustomImage
|
||||
src={chat?.item?.image}
|
||||
alt="Item image"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full min-w-0">
|
||||
<div className="w-full flex items-center gap-1 justify-between min-w-0">
|
||||
<h5 className="font-medium truncate" title={user?.name}>
|
||||
{user?.name}
|
||||
</h5>
|
||||
<span className="text-xs">{formatTime(chat?.last_message_time)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
<p
|
||||
className="truncate text-sm"
|
||||
title={chat?.item?.translated_name || chat?.item?.name}
|
||||
>
|
||||
{chat?.item?.translated_name || chat?.item?.name}
|
||||
</p>
|
||||
{isUnread && !isActive && (
|
||||
<span className="flex items-center justify-center bg-primary text-white rounded-full px-2 py-1 text-xs">
|
||||
{chat?.unread_chat_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CustomLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatListCard;
|
||||
25
components/PagesComponent/Chat/ChatListCardSkeleton.jsx
Normal file
25
components/PagesComponent/Chat/ChatListCardSkeleton.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ChatListCardSkeleton = () => {
|
||||
return (
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar skeleton */}
|
||||
<Skeleton className="w-12 h-12 rounded-full" />
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Name skeleton */}
|
||||
<Skeleton className="h-4 w-[40%] mb-2 rounded-md" />
|
||||
|
||||
{/* Message skeleton */}
|
||||
<Skeleton className="h-3 w-[70%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Time skeleton */}
|
||||
<Skeleton className="h-3 w-[15%] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatListCardSkeleton
|
||||
343
components/PagesComponent/Chat/ChatMessages.jsx
Normal file
343
components/PagesComponent/Chat/ChatMessages.jsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { userSignUpData } from "@/redux/reducer/authSlice";
|
||||
import {
|
||||
formatChatMessageTime,
|
||||
formatMessageDate,
|
||||
formatPriceAbbreviated,
|
||||
t,
|
||||
} from "@/utils";
|
||||
import { getMessagesApi } from "@/utils/api";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader2, ChevronUp } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
const SendMessage = dynamic(() => import("./SendMessage"), { ssr: false });
|
||||
import GiveReview from "./GiveReview";
|
||||
import { getNotification } from "@/redux/reducer/globalStateSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Skeleton component for chat messages
|
||||
const ChatMessagesSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* Skeleton for date separator */}
|
||||
|
||||
{/* Received message skeletons */}
|
||||
<div className="flex flex-col gap-1 w-[65%] max-w-[80%]">
|
||||
<Skeleton className="h-16 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Sent message skeletons */}
|
||||
<div className="flex flex-col gap-1 w-[70%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-10 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Image message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[50%] max-w-[80%]">
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Audio message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Another message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-[60%] max-w-[80%] self-end">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] self-end rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Another message skeleton */}
|
||||
<div className="flex flex-col gap-1 w-[45%] max-w-[80%]">
|
||||
<Skeleton className="h-14 w-full rounded-md" />
|
||||
<Skeleton className="h-3 w-[30%] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageContent = (message, isCurrentUser) => {
|
||||
const baseTextClass = isCurrentUser
|
||||
? "text-white bg-primary p-2 rounded-md w-fit"
|
||||
: "text-black bg-border p-2 rounded-md w-fit";
|
||||
|
||||
const audioStyles = isCurrentUser ? "border-primary" : "border-border";
|
||||
|
||||
switch (message.message_type) {
|
||||
case "audio":
|
||||
return (
|
||||
<audio
|
||||
src={message.audio}
|
||||
controls
|
||||
className={`w-full sm:w-[70%] ${
|
||||
isCurrentUser ? "self-end" : "self-start"
|
||||
} rounded-md border-2 ${audioStyles}`}
|
||||
controlsList="nodownload"
|
||||
type="audio/mpeg"
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
|
||||
case "file":
|
||||
return (
|
||||
<div className={`${baseTextClass}`}>
|
||||
<CustomImage
|
||||
src={message.file}
|
||||
alt="Chat Image"
|
||||
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "file_and_text":
|
||||
return (
|
||||
<div className={`${baseTextClass} flex flex-col gap-2`}>
|
||||
<CustomImage
|
||||
src={message.file}
|
||||
alt="Chat Image"
|
||||
className="rounded-md w-auto h-auto max-h-[250px] max-w-[250px] object-contain"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
<div className="border-white/20">{message.message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<p
|
||||
className={`${baseTextClass} whitespace-pre-wrap ${
|
||||
isCurrentUser ? "self-end" : "self-start"
|
||||
}`}
|
||||
>
|
||||
{message?.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ChatMessages = ({
|
||||
selectedChatDetails,
|
||||
isSelling,
|
||||
setSelectedChatDetails,
|
||||
setBuyer,
|
||||
chatId,
|
||||
}) => {
|
||||
const notification = useSelector(getNotification);
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [currentMessagesPage, setCurrentMessagesPage] = useState(1);
|
||||
const [hasMoreChatMessages, setHasMoreChatMessages] = useState(false);
|
||||
const [isLoadPrevMesg, setIsLoadPrevMesg] = useState(false);
|
||||
const [IsLoading, setIsLoading] = useState(false);
|
||||
const [showReviewDialog, setShowReviewDialog] = useState(false);
|
||||
const lastMessageDate = useRef(null);
|
||||
const isAskForReview =
|
||||
!isSelling &&
|
||||
selectedChatDetails?.item?.status === "sold out" &&
|
||||
!selectedChatDetails?.item?.review &&
|
||||
Number(selectedChatDetails?.item?.sold_to) ===
|
||||
Number(selectedChatDetails?.buyer_id);
|
||||
|
||||
const user = useSelector(userSignUpData);
|
||||
const userId = user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChatDetails?.id) {
|
||||
fetchChatMessgaes(1);
|
||||
}
|
||||
}, [selectedChatDetails?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
notification?.type === "chat" &&
|
||||
Number(notification?.item_offer_id) === Number(chatId) &&
|
||||
(notification?.user_type === "Seller" ? !isSelling : isSelling)
|
||||
) {
|
||||
const newMessage = {
|
||||
message_type: notification?.message_type_temp,
|
||||
message: notification?.message,
|
||||
sender_id: Number(notification?.sender_id),
|
||||
created_at: notification?.created_at,
|
||||
audio: notification?.audio,
|
||||
file: notification?.file,
|
||||
id: Number(notification?.id),
|
||||
item_offer_id: Number(notification?.item_offer_id),
|
||||
updated_at: notification?.updated_at,
|
||||
};
|
||||
|
||||
setChatMessages((prev) => [...prev, newMessage]);
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
const fetchChatMessgaes = async (page) => {
|
||||
try {
|
||||
page > 1 ? setIsLoadPrevMesg(true) : setIsLoading(true);
|
||||
const response = await getMessagesApi.chatMessages({
|
||||
item_offer_id: selectedChatDetails?.id,
|
||||
page,
|
||||
});
|
||||
if (response?.data?.error === false) {
|
||||
const currentPage = Number(response?.data?.data?.current_page);
|
||||
const lastPage = Number(response?.data?.data?.last_page);
|
||||
const hasMoreChatMessages = currentPage < lastPage;
|
||||
const chatMessages = (response?.data?.data?.data).reverse();
|
||||
setCurrentMessagesPage(currentPage);
|
||||
setHasMoreChatMessages(hasMoreChatMessages);
|
||||
page > 1
|
||||
? setChatMessages((prev) => [...chatMessages, ...prev])
|
||||
: setChatMessages(chatMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoadPrevMesg(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto bg-muted p-4 flex flex-col gap-2.5 relative">
|
||||
{IsLoading ? (
|
||||
<ChatMessagesSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Show review dialog if open */}
|
||||
{showReviewDialog && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<GiveReview
|
||||
itemId={selectedChatDetails?.item_id}
|
||||
sellerId={selectedChatDetails?.seller_id}
|
||||
onClose={() => setShowReviewDialog(false)}
|
||||
onSuccess={handleReviewSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* button to load previous messages */}
|
||||
{hasMoreChatMessages && !IsLoading && (
|
||||
<div className="absolute top-3 left-0 right-0 z-10 flex justify-center pb-2">
|
||||
<button
|
||||
onClick={() => fetchChatMessgaes(currentMessagesPage + 1)}
|
||||
disabled={isLoadPrevMesg}
|
||||
className="text-primary text-sm font-medium px-3 py-1.5 bg-white/90 rounded-full shadow-md hover:bg-white flex items-center gap-1.5"
|
||||
>
|
||||
{isLoadPrevMesg ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("loading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
{t("loadPreviousMessages")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* offer price */}
|
||||
{!hasMoreChatMessages &&
|
||||
selectedChatDetails?.amount > 0 &&
|
||||
(() => {
|
||||
const isSeller = isSelling;
|
||||
const containerClasses = `flex flex-col gap-1 rounded-md p-2 w-fit ${
|
||||
isSeller ? "bg-border" : "bg-primary text-white self-end"
|
||||
}`;
|
||||
const label = isSeller ? t("offer") : t("yourOffer");
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<p className="text-sm">{label}</p>
|
||||
<span className="text-xl font-medium">
|
||||
{selectedChatDetails.formatted_amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* chat messages */}
|
||||
{chatMessages &&
|
||||
chatMessages.length > 0 &&
|
||||
chatMessages.map((message) => {
|
||||
const messageDate = formatMessageDate(message.created_at);
|
||||
const showDateSeparator =
|
||||
messageDate !== lastMessageDate.current;
|
||||
if (showDateSeparator) {
|
||||
lastMessageDate.current = messageDate;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={message?.id}>
|
||||
{showDateSeparator && (
|
||||
<p className="text-xs bg-[#f1f1f1] py-1 px-2 rounded-lg text-muted-foreground my-5 mx-auto">
|
||||
{messageDate}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{message.sender_id === userId ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 max-w-[80%] self-end",
|
||||
message.message_type === "audio" && "w-full"
|
||||
)}
|
||||
key={message?.id}
|
||||
>
|
||||
{renderMessageContent(message, true)}
|
||||
<p className="text-xs text-muted-foreground ltr:text-right rtl:text-left">
|
||||
{formatChatMessageTime(message?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 max-w-[80%]",
|
||||
message.message_type === "audio" && "w-full"
|
||||
)}
|
||||
key={message?.id}
|
||||
>
|
||||
{renderMessageContent(message, false)}
|
||||
<p className="text-xs text-muted-foreground ltr:text-left rtl:text-right">
|
||||
{formatChatMessageTime(message?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAskForReview && (
|
||||
<GiveReview
|
||||
key={`review-${selectedChatDetails?.id}`}
|
||||
itemId={selectedChatDetails?.item_id}
|
||||
setSelectedChatDetails={setSelectedChatDetails}
|
||||
setBuyer={setBuyer}
|
||||
/>
|
||||
)}
|
||||
<SendMessage
|
||||
key={`send-${selectedChatDetails?.id}`}
|
||||
selectedChatDetails={selectedChatDetails}
|
||||
setChatMessages={setChatMessages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
173
components/PagesComponent/Chat/GiveReview.jsx
Normal file
173
components/PagesComponent/Chat/GiveReview.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FaStar } from "react-icons/fa";
|
||||
import { t } from "@/utils";
|
||||
import { addItemReviewApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const GiveReview = ({ itemId, setSelectedChatDetails, setBuyer }) => {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [review, setReview] = useState("");
|
||||
const [errors, setErrors] = useState({
|
||||
rating: "",
|
||||
review: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleRatingClick = (selectedRating) => {
|
||||
setRating(selectedRating);
|
||||
setErrors((prev) => ({ ...prev, rating: "" }));
|
||||
};
|
||||
|
||||
const handleMouseEnter = (starValue) => {
|
||||
setHoveredRating(starValue);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredRating(0);
|
||||
};
|
||||
|
||||
const handleReviewChange = (e) => {
|
||||
setReview(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, review: "" }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {
|
||||
rating: "",
|
||||
review: "",
|
||||
};
|
||||
let isValid = true;
|
||||
|
||||
if (rating === 0) {
|
||||
newErrors.rating = t("pleaseSelectRating");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!review.trim()) {
|
||||
newErrors.review = t("pleaseWriteReview");
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const res = await addItemReviewApi.addItemReview({
|
||||
item_id: itemId,
|
||||
review,
|
||||
ratings: rating,
|
||||
});
|
||||
if (res?.data?.error === false) {
|
||||
toast.success(res?.data?.message);
|
||||
setSelectedChatDetails((prev) => ({
|
||||
...prev,
|
||||
item: {
|
||||
...prev.item,
|
||||
review: res?.data?.data,
|
||||
},
|
||||
}));
|
||||
setBuyer((prev) => ({
|
||||
...prev,
|
||||
BuyerChatList: prev.BuyerChatList.map((chatItem) =>
|
||||
chatItem?.item?.id === Number(res?.data?.data?.item_id)
|
||||
? {
|
||||
...chatItem,
|
||||
item: {
|
||||
...chatItem.item,
|
||||
review: res?.data?.data?.review, // use review from API
|
||||
},
|
||||
}
|
||||
: chatItem
|
||||
),
|
||||
}));
|
||||
setRating(0);
|
||||
setReview("");
|
||||
setErrors({
|
||||
rating: "",
|
||||
review: "",
|
||||
});
|
||||
} else {
|
||||
toast.error(res?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error(t("somethingWentWrong"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted p-4">
|
||||
<div className="rounded-lg p-4 bg-white">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-base font-medium mb-2">{t("rateSeller")}</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">{t("rateYourExp")}</p>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className="p-1 focus:outline-none"
|
||||
onClick={() => handleRatingClick(starValue)}
|
||||
onMouseEnter={() => handleMouseEnter(starValue)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
aria-label={`Rate ${starValue} stars out of 5`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<FaStar
|
||||
className={`text-3xl ${
|
||||
(hoveredRating || rating) >= starValue
|
||||
? "text-yellow-400"
|
||||
: "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.rating && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.rating}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Textarea
|
||||
placeholder={t("writeAReview")}
|
||||
value={review}
|
||||
onChange={handleReviewChange}
|
||||
className={`min-h-[100px] resize-none border-gray-200 rounded ${
|
||||
errors.review ? "border-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
{errors.review && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.review}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="bg-primary text-white px-6"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GiveReview;
|
||||
21
components/PagesComponent/Chat/NoChatFound.jsx
Normal file
21
components/PagesComponent/Chat/NoChatFound.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { t } from "@/utils";
|
||||
import { MdArrowBack } from "react-icons/md";
|
||||
|
||||
const NoChatFound = ({ handleBack, isLargeScreen }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-center items-center justify-center">
|
||||
<h5 className="text-primary text-2xl font-medium">{t("noChatFound")}</h5>
|
||||
<p>{t("startConversation")}</p>
|
||||
|
||||
{!isLargeScreen && (
|
||||
<Button className="w-fit" onClick={handleBack}>
|
||||
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoChatFound;
|
||||
23
components/PagesComponent/Chat/NoChatListFound.jsx
Normal file
23
components/PagesComponent/Chat/NoChatListFound.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { t } from "@/utils";
|
||||
import noChatListFound from "../../../public/assets/no_data_found_illustrator.svg";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const NoChatListFound = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<CustomImage
|
||||
src={noChatListFound}
|
||||
alt="no chat list found"
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-[200px] h-auto aspect-square"
|
||||
/>
|
||||
<h3 className="font-medium text-2xl text-primary text-center">
|
||||
{t("noConversationsFound")}
|
||||
</h3>
|
||||
<span className="text-sm text-center">{t("noChatsAvailable")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoChatListFound;
|
||||
137
components/PagesComponent/Chat/SelectedChatHeader.jsx
Normal file
137
components/PagesComponent/Chat/SelectedChatHeader.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { HiOutlineDotsVertical } from "react-icons/hi";
|
||||
import { t } from "@/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import CustomLink from "@/components/Common/CustomLink";
|
||||
import { blockUserApi, unBlockUserApi } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { getIsRtl } from "@/redux/reducer/languageSlice";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
import { MdArrowBack } from "react-icons/md";
|
||||
|
||||
const SelectedChatHeader = ({
|
||||
selectedChat,
|
||||
isSelling,
|
||||
setSelectedChat,
|
||||
handleBack,
|
||||
isLargeScreen,
|
||||
}) => {
|
||||
const isBlocked = selectedChat?.user_blocked;
|
||||
const userData = isSelling ? selectedChat?.buyer : selectedChat?.seller;
|
||||
const itemData = selectedChat?.item;
|
||||
const isRTL = useSelector(getIsRtl);
|
||||
|
||||
const handleBlockUser = async (id) => {
|
||||
try {
|
||||
const response = await blockUserApi.blockUser({
|
||||
blocked_user_id: userData?.id,
|
||||
});
|
||||
|
||||
if (response?.data?.error === false) {
|
||||
setSelectedChat((prevData) => ({
|
||||
...prevData,
|
||||
user_blocked: true,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
} else {
|
||||
toast.error(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnBlockUser = async (id) => {
|
||||
try {
|
||||
const response = await unBlockUserApi.unBlockUser({
|
||||
blocked_user_id: userData?.id,
|
||||
});
|
||||
if (response?.data.error === false) {
|
||||
setSelectedChat((prevData) => ({
|
||||
...prevData,
|
||||
user_blocked: false,
|
||||
}));
|
||||
toast.success(response?.data?.message);
|
||||
} else {
|
||||
toast.error(response?.data?.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{!isLargeScreen && (
|
||||
<button onClick={handleBack}>
|
||||
<MdArrowBack size={20} className="rtl:scale-x-[-1]" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative flex-shrink-0">
|
||||
<CustomLink href={`/seller/${userData?.id}`}>
|
||||
<CustomImage
|
||||
src={userData?.profile}
|
||||
alt="avatar"
|
||||
width={56}
|
||||
height={56}
|
||||
className="w-[56px] h-auto aspect-square object-cover rounded-full"
|
||||
/>
|
||||
</CustomLink>
|
||||
<CustomImage
|
||||
src={userData?.profile}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-[24px] h-auto aspect-square object-cover rounded-full absolute top-[32px] bottom-[-6px] right-[-6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full min-w-0">
|
||||
<CustomLink
|
||||
href={`/seller/${userData?.id}`}
|
||||
className="font-medium truncate"
|
||||
title={userData?.name}
|
||||
>
|
||||
{userData?.name}
|
||||
</CustomLink>
|
||||
<p
|
||||
className="truncate text-sm"
|
||||
title={itemData?.translated_name || itemData?.name}
|
||||
>
|
||||
{itemData?.translated_name || itemData?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu for Actions */}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="self-end">
|
||||
<HiOutlineDotsVertical size={22} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isRTL ? "start" : "end"}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={isBlocked ? handleUnBlockUser : handleBlockUser}
|
||||
>
|
||||
<span>{isBlocked ? t("unblock") : t("block")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
{itemData?.formatted_price}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedChatHeader;
|
||||
256
components/PagesComponent/Chat/SendMessage.jsx
Normal file
256
components/PagesComponent/Chat/SendMessage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
import { sendMessageApi } from "@/utils/api";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { IoMdAttach, IoMdSend } from "react-icons/io";
|
||||
import { FaMicrophone, FaRegStopCircle } from "react-icons/fa";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useReactMediaRecorder } from "react-media-recorder";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "@/utils";
|
||||
import CustomImage from "@/components/Common/CustomImage";
|
||||
|
||||
const SendMessage = ({ selectedChatDetails, setChatMessages }) => {
|
||||
const isAllowToChat =
|
||||
selectedChatDetails?.item?.status === "approved" ||
|
||||
selectedChatDetails?.item?.status === "featured";
|
||||
|
||||
if (!isAllowToChat) {
|
||||
return (
|
||||
<div className="p-4 border-t text-center text-muted-foreground">
|
||||
{t("thisAd")} {selectedChatDetails?.item?.status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const id = selectedChatDetails?.id;
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Voice recording setup
|
||||
const { status, startRecording, stopRecording, mediaBlobUrl, error } =
|
||||
useReactMediaRecorder({
|
||||
audio: true,
|
||||
blobPropertyBag: { type: "audio/mpeg" },
|
||||
});
|
||||
|
||||
const isRecording = status === "recording";
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
|
||||
// Format recording duration as mm:ss
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// Timer for recording
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (isRecording) {
|
||||
timer = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingDuration(0);
|
||||
}
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
stopRecording();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle recorded audio
|
||||
useEffect(() => {
|
||||
if (mediaBlobUrl && status === "stopped") {
|
||||
handleRecordedAudio();
|
||||
}
|
||||
}, [mediaBlobUrl, status]);
|
||||
|
||||
const handleRecordedAudio = async () => {
|
||||
try {
|
||||
const response = await fetch(mediaBlobUrl);
|
||||
const blob = await response.blob();
|
||||
const audioFile = new File([blob], "recording.mp3", {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
sendMessage(audioFile);
|
||||
} catch (err) {
|
||||
console.error("Error processing audio:", err);
|
||||
toast.error("Failed to process recording");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check if file is an image
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error("Only image files (JPEG, PNG, JPG) are allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL for image
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(fileUrl);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const removeSelectedFile = () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl("");
|
||||
// Reset file input value to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (audioFile = null) => {
|
||||
if ((!message.trim() && !selectedFile && !audioFile) || isSending) return;
|
||||
|
||||
const params = {
|
||||
item_offer_id: id,
|
||||
message: message ? message : "",
|
||||
file: selectedFile ? selectedFile : "",
|
||||
audio: audioFile ? audioFile : "",
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
const response = await sendMessageApi.sendMessage(params);
|
||||
|
||||
if (!response?.data?.error) {
|
||||
setChatMessages((prev) => [...prev, response.data.data]);
|
||||
setMessage("");
|
||||
removeSelectedFile();
|
||||
} else {
|
||||
toast.error(response?.data?.message || "Failed to send message");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Error sending message");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceButtonClick = () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
switch (error) {
|
||||
case "permission_denied":
|
||||
toast.error(t("microphoneAccessDenied"));
|
||||
break;
|
||||
case "no_specified_media_found":
|
||||
toast.error(t("noMicrophoneFound"));
|
||||
break;
|
||||
default:
|
||||
toast.error(t("somethingWentWrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* File Preview */}
|
||||
{previewUrl && (
|
||||
<div className="px-4 pt-2 pb-1">
|
||||
<div className="relative w-32 h-32 border rounded-md overflow-hidden group">
|
||||
<CustomImage
|
||||
src={previewUrl}
|
||||
alt="File preview"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
<button
|
||||
onClick={removeSelectedFile}
|
||||
className="absolute top-1 right-1 bg-black/70 text-white p-1 rounded-full opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t flex items-center gap-2">
|
||||
{!isRecording && (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/png,image/jpg"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<IoMdAttach size={20} className="text-muted-foreground" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRecording ? (
|
||||
<div className="flex-1 py-2 px-3 bg-red-50 text-red-500 rounded-md flex items-center justify-center font-medium">
|
||||
{t("recording")} {formatDuration(recordingDuration)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
type="text"
|
||||
placeholder="Message..."
|
||||
className="flex-1 outline-none border px-3 py-1 rounded-md"
|
||||
value={message}
|
||||
rows={2}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="p-2 bg-primary text-white rounded-md"
|
||||
disabled={isSending}
|
||||
onClick={
|
||||
message.trim() || selectedFile
|
||||
? () => sendMessage()
|
||||
: handleVoiceButtonClick
|
||||
}
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : message.trim() || selectedFile ? (
|
||||
<IoMdSend size={20} className="rtl:scale-x-[-1]" />
|
||||
) : isRecording ? (
|
||||
<FaRegStopCircle size={20} />
|
||||
) : (
|
||||
<FaMicrophone size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendMessage;
|
||||
Reference in New Issue
Block a user