classify web

This commit is contained in:
Husanjonazamov
2026-02-24 12:52:49 +05:00
commit 64af77101f
310 changed files with 45449 additions and 0 deletions

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

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

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

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

View 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

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

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

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

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

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

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