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>
This commit is contained in:
Arda Samadi 2026-07-13 15:09:01 +03:30
parent d694040bf7
commit 7867db425e
7 changed files with 115 additions and 20 deletions

View File

@ -19,11 +19,14 @@ export const ContactButton = ({ product }: { product: ProductDetail }) => {
return;
}
if (!product.seller?.id) return;
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
});
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
};
return (
<>

View File

@ -330,9 +330,12 @@ export const ProductDetailView = memo(function ProductDetailView({
return;
}
if (!product.seller?.id) return;
createChatThread(product.seller.id, {
onSuccess: (t) => navigate(`/profile/threads/${t.id}`),
});
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (t) => navigate(`/profile/threads/${t.id}`),
}
);
};
return (

View File

@ -24,11 +24,14 @@ export const ProductHeader = ({ product }: ProductHeaderProps) => {
return;
}
if (!product.seller?.id) return;
createChatThread(product.seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
});
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
}
);
};
return (

View File

@ -1,4 +1,5 @@
import { Reply, MoreVertical, Trash2, Check } from "lucide-react";
import { Link } from "@remix-run/react";
import {
DropdownMenu,
@ -81,7 +82,34 @@ export const MessageBubble = ({
{replyToMessageContent || "پاسخ به پیام"}
</div>
)}
<p>{message.content}</p>
{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">

View File

@ -156,7 +156,10 @@ const ThreadsList = ({
<p
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
>
{thread.lastMessage?.content || "بدون پیام"}
{thread.lastMessage?.content ||
(thread.lastMessage?.product
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
: "بدون پیام")}
</p>
</div>
</div>

View File

@ -206,15 +206,21 @@ export function useGetThreadMessages(threadId: string) {
export function useCreateChatThread() {
const token = useAuthToken();
return useMutation({
mutationFn: async (participantId: string) => {
mutationFn: async (
input: string | { participantId: string; productId?: string }
) => {
if (!token) {
throw new Error("Authentication token and user ID are required");
}
// Back-compat: callers may pass a bare participantId string.
const { participantId, productId } =
typeof input === "string" ? { participantId: input, productId: undefined } : input;
try {
const chatApi = createChatApi(token);
return await chatApi.apiChatV1ThreadsCreateCreate({
data: {
participantId: participantId,
participantId,
...(productId ? { productId } : {}),
},
});
} catch (error) {

View File

@ -2336,14 +2336,51 @@ export interface LastMessage {
*/
readonly senderUsername?: string;
/**
*
*
* @type {string}
* @memberof LastMessage
*/
readonly createdAt?: string;
/**
* Product shared into the chat, if this message is a product card.
* @type {ChatProductCard}
* @memberof LastMessage
*/
readonly product?: ChatProductCard | null;
}
/**
*
* Minimal product info rendered as a card inside a chat.
* @export
* @interface ChatProductCard
*/
export interface ChatProductCard {
/**
*
* @type {string}
* @memberof ChatProductCard
*/
readonly id?: string;
/**
*
* @type {string}
* @memberof ChatProductCard
*/
readonly title?: string;
/**
*
* @type {string}
* @memberof ChatProductCard
*/
readonly basePrice?: string;
/**
*
* @type {string}
* @memberof ChatProductCard
*/
readonly primaryImage?: string | null;
}
/**
*
* @export
* @interface Login
*/
@ -2479,11 +2516,17 @@ export interface MessageList {
*/
readonly isDeleted?: boolean;
/**
*
*
* @type {boolean}
* @memberof MessageList
*/
readonly isThreadCreator?: boolean;
/**
* Product shared into the chat, if this message is a product card.
* @type {ChatProductCard}
* @memberof MessageList
*/
readonly product?: ChatProductCard | null;
}
/**
*
@ -6464,6 +6507,12 @@ export interface ThreadCreate {
* @memberof ThreadCreate
*/
participantId: string;
/**
* Optional product the chat is about; shared into the thread as a card
* @type {string}
* @memberof ThreadCreate
*/
productId?: string | null;
}
/**
*