Vitron-Front/app/components/thread/MessageBubble.tsx
Arda Samadi 7867db425e feat(chat): share the product into the chat + fix empty-thread preview
- Product-page chat buttons pass the product id when starting a thread, so it's
  shared into the conversation as a card the seller can see (and tap through).
- MessageBubble renders the product card (image, title, price → product page);
  card-only messages no longer render an empty bubble.
- Inbox preview shows 🛍 <product> instead of "بدون پیام" for product-card
  threads. ChatProductCard type + product on message/last-message types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:09:01 +03:30

243 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
import { Link } from "@remix-run/react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { formatTime, formatDate2 } from "../../utils/helpers";
import { MessageList, ThreadParticipant } from "../../../src/api/types/models";
import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog";
import { Button } from "../ui/button";
import { useState, useRef, useEffect } from "react";
// Message bubble component
export const MessageBubble = ({
message,
isCurrentUser,
onReply,
onDelete,
replyToMessageContent,
onReplyClick,
isHighlighted = false,
otherParticipant,
}: {
message: MessageList;
isCurrentUser: boolean;
onReply: (message: MessageList) => void;
onDelete?: (messageId: string) => void;
replyToMessageContent?: string | undefined;
onReplyClick?: (replyToId: string) => void;
isHighlighted?: boolean;
otherParticipant: ThreadParticipant | undefined;
}) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showDropdownMenu, setShowDropdownMenu] = useState(false);
const messageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isHighlighted && messageRef.current) {
messageRef.current.classList.add("highlight-message");
// Remove highlight after animation completes
const timeout = setTimeout(() => {
if (messageRef.current) {
messageRef.current.classList.remove("highlight-message");
}
}, 1500);
return () => clearTimeout(timeout);
}
}, [isHighlighted]);
return (
<>
<div
className={`flex items-end gap-1 ${!isCurrentUser ? "flex-row-reverse" : "justify-start flex-row"} mb-2`}
>
<div
ref={messageRef}
className={`rounded-xl px-4 py-3 max-w-[70%] ${
isCurrentUser ? "bg-BLUE2 text-WHITE" : "bg-GRAY3"
}`}
>
{message.replyTo && (
<div
className={`text-xs italic mb-1 pb-1 border-b ${
isCurrentUser ? "border-WHITE/30" : "border-GRAY2/30"
} cursor-pointer hover:opacity-80`}
onClick={() => onReplyClick?.(message.replyTo || "")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onReplyClick?.(message.replyTo || "");
}
}}
role="button"
tabIndex={0}
aria-label="پیام اصلی را نمایش بده"
>
{/* Display replied message content if available */}
{replyToMessageContent || "پاسخ به پیام"}
</div>
)}
{message.product && (
<Link
to={`/product/${message.product.id}`}
className="flex items-center gap-2 mb-1 w-[210px] max-w-full rounded-lg bg-WHITE border border-GRAY3 p-2"
>
{message.product.primaryImage ? (
<img
src={message.product.primaryImage}
alt=""
className="w-12 h-12 rounded-md object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-md bg-WHITE3 shrink-0" />
)}
<div className="min-w-0 text-right">
<p className="text-R12 text-BLACK truncate">
{message.product.title}
</p>
{message.product.basePrice && (
<p className="text-R10 text-GRAY mt-0.5">
{parseInt(message.product.basePrice).toLocaleString("fa-IR")}{" "}
تومان
</p>
)}
</div>
</Link>
)}
{message.content && <p>{message.content}</p>}
</div>
<div className="flex flex-col justify-between items-start">
<div className="flex flex-col gap-1 items-start">
<DropdownMenu
open={showDropdownMenu}
onOpenChange={setShowDropdownMenu}
modal={false}
>
<DropdownMenuTrigger asChild>
<button
onClick={() => setShowDropdownMenu(true)}
className="w-5 h-5 hover:bg-GRAY2 rounded-full flex items-center justify-center cursor-pointer"
aria-label="more options icon"
>
<MoreVertical size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 z-1">
<DropdownMenuItem
onClick={() => onReply(message)}
className="cursor-pointer justify-end"
>
<Reply size={14} />
<span className="text-R12">پاسخ</span>
</DropdownMenuItem>
{isCurrentUser && (
<DropdownMenuItem
onClick={() => {
setShowDropdownMenu(false);
setShowDeleteDialog(true);
}}
className="cursor-pointer text-red-500 justify-end"
>
<Trash2 size={14} />
<span className="text-R12">حذف پیام</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-1">
<p
className={`text-R10 ${
!isCurrentUser ? "text-right" : "text-left"
} text-GRAY`}
>
{formatTime(message.createdAt || "")}
</p>
{isCurrentUser && (
<div className="text-GRAY">
{new Date(message.createdAt || "").getTime() <
new Date(otherParticipant?.lastReadAt || "").getTime() ? (
<div className="flex items-center">
<Check size={12} className="text-blue-500" />
<Check
size={12}
className="text-blue-500 relative left-2"
/>
</div>
) : (
<Check size={12} />
)}
</div>
)}
</div>
</div>
</div>
<DeleteConfirmBox
showDeleteDialog={showDeleteDialog}
setShowDeleteDialog={setShowDeleteDialog}
confirmDelete={() => onDelete?.(message.id || "")}
/>
</>
);
};
const DeleteConfirmBox = ({
showDeleteDialog,
setShowDeleteDialog,
confirmDelete,
}: {
showDeleteDialog: boolean;
setShowDeleteDialog: (show: boolean) => void;
confirmDelete: () => void;
}) => {
return (
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogOverlay className="bg-BLACK/20" />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-3 items-center w-full">
<Trash2 size={24} className="text-RED" />
<p className="text-B12 font-bold mt-1">حذف پیام</p>
<p className="text-R12">میخواهید این پیام را حذف کنید؟</p>
</div>
<div className="flex flex-row gap-6 w-full">
<Button
className="w-[100%]"
size={"sm"}
variant="dark"
onClick={() => setShowDeleteDialog(false)}
>
انصراف
</Button>
<Button
className="w-[100%]"
size={"sm"}
variant="primary"
onClick={() => {
confirmDelete();
setShowDeleteDialog(false);
}}
>
حذف پیام
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
// Date separator component
export const DateSeparator = ({ date }: { date: string }) => {
return (
<div className="flex items-center justify-center my-4">
<div className="border-t border-GRAY3 flex-grow"></div>
<div className="px-3 text-R12 text-GRAY">{formatDate2(date)}</div>
<div className="border-t border-GRAY3 flex-grow"></div>
</div>
);
};