- MessageInput: paperclip button (team-only, controlled by teamUserIds from ThreadDetail) uploads through the media service and hands the URL to the WebSocket; Enter now inserts a newline — sending is button-only since accidental Enter-sends were noisy for staff replies. - MessageBubble: renders image messages inline (tap to open), shows the coworker's username at the top of team-side bubbles so staff can tell who typed what. - MessageList / ThreadChatPage: any message from a store team member (owner or active staff) renders on the "our" side of the bubble even for a viewer who isn't the sender, using team_user_ids from ThreadDetail. Delete option stays gated to the actual sender. - ThreadsPage: inbox now shows smart-relative timestamps (today HH:MM / دیروز HH:MM / N روز پیش / Persian date), plus a reply-state icon per row (red bell = customer waiting, blue double-check = you replied and they've seen it, nothing = you replied not yet seen). Image-only last messages preview as "📷 تصویر". - Push notifications: on mount we re-POST the current PushSubscription so a browser-rotated sub or a backend row deleted after a 410 auto-heals. - useWebSocket: SendMessagePayload and NewMessageEvent carry image_url so the server relay can round-trip images. - API types: MessageList.imageUrl, LastMessage.imageUrl, ThreadList.lastMessageFromTeam/otherPartySeenLast, ThreadDetail.teamUserIds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
226 lines
8.1 KiB
TypeScript
226 lines
8.1 KiB
TypeScript
import { Link } from "@remix-run/react";
|
|
import AppInput from "../AppInput";
|
|
import { useState } from "react";
|
|
import {
|
|
useGetUserThreads,
|
|
useGetStoreThreads,
|
|
} from "../../requestHandler/use-chat-hooks";
|
|
import {
|
|
ThreadList,
|
|
ThreadParticipant,
|
|
UserProfile,
|
|
} from "../../../src/api/types";
|
|
import { useServiceGetProfile } from "../../requestHandler/use-profile-hooks";
|
|
import UiProvider from "../UiProvider";
|
|
import SellerLogo from "../SellerLogo";
|
|
import { CheckCheck, MessageCircleWarning, Search, User } from "lucide-react";
|
|
import ProfilePagesHeader from "../ProfilePagesHeader";
|
|
|
|
// Persian-style relative timestamp for the inbox list. Older messages need a
|
|
// day label so the seller can tell "today's message" from "3 months ago"
|
|
// without reading the date.
|
|
function formatThreadTimestamp(iso: string | null | undefined): string {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "";
|
|
const now = new Date();
|
|
const startOfDay = (x: Date) =>
|
|
new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
const dayDiff = Math.round(
|
|
(startOfDay(now) - startOfDay(d)) / (1000 * 60 * 60 * 24),
|
|
);
|
|
const timePart = d.toLocaleTimeString("fa-IR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
if (dayDiff <= 0) return timePart; // today: just HH:MM
|
|
if (dayDiff === 1) return `دیروز ${timePart}`;
|
|
if (dayDiff < 7) return `${dayDiff} روز پیش`;
|
|
// Older than a week: show the date in Persian calendar.
|
|
return d.toLocaleDateString("fa-IR");
|
|
}
|
|
|
|
interface ThreadsPageProps {
|
|
type: "user" | "store";
|
|
storeId?: string;
|
|
}
|
|
|
|
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
|
const [searchValue, setSearchValue] = useState("");
|
|
// Seller dashboard shows the STORE's inbox; the profile page shows the user's
|
|
// personal (buyer) chats. Two different endpoints so the two never mix.
|
|
const userThreads = useGetUserThreads();
|
|
const storeThreads = useGetStoreThreads(type === "store" ? storeId : undefined);
|
|
const {
|
|
data: threads,
|
|
isLoading,
|
|
isError,
|
|
refetch,
|
|
} = type === "store" ? storeThreads : userThreads;
|
|
const sortedThreads = threads?.sort((a, b) => {
|
|
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
|
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
|
return dateB.getTime() - dateA.getTime();
|
|
});
|
|
const { data: dataProfile, isFetching: isFetchingProfile } =
|
|
useServiceGetProfile();
|
|
|
|
return (
|
|
<div
|
|
className={`flex flex-col w-full ${isLoading || isFetchingProfile ? "" : "pb-20"}`}
|
|
>
|
|
<ProfilePagesHeader title="پیامها" />
|
|
<div className="w-full px-4 py-2">
|
|
<div className="w-full h-[40px] flex items-center gap-2 bg-WHITE2 rounded-[10px]">
|
|
<AppInput
|
|
inputIcon={Search}
|
|
inputDir="rtl"
|
|
inputTitle={""}
|
|
className="bg-WHITE2"
|
|
inputValue={searchValue}
|
|
inputPlaceholder={"جستجو..."}
|
|
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setSearchValue(value);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<UiProvider
|
|
isLoading={isLoading || isFetchingProfile}
|
|
isError={isError}
|
|
isEmpty={!!(threads && threads.length === 0)}
|
|
onRetry={refetch}
|
|
type="threads"
|
|
>
|
|
<ThreadsList
|
|
searchValue={searchValue}
|
|
threads={sortedThreads}
|
|
dataProfile={dataProfile}
|
|
type={type}
|
|
storeId={storeId}
|
|
/>
|
|
</UiProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ThreadsList = ({
|
|
searchValue,
|
|
threads,
|
|
dataProfile,
|
|
type,
|
|
storeId,
|
|
}: {
|
|
searchValue: string;
|
|
threads: ThreadList[] | undefined;
|
|
dataProfile: UserProfile | undefined;
|
|
type: "user" | "store";
|
|
storeId?: string;
|
|
}) => {
|
|
const filteredThreads = threads?.filter((thread) => {
|
|
return thread.participants?.some((participant: ThreadParticipant) =>
|
|
participant.username?.toLowerCase().includes(searchValue.toLowerCase())
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="flex flex-col w-full">
|
|
{filteredThreads?.map((thread) => {
|
|
const otherParticipant = thread.participants?.find(
|
|
(participant: ThreadParticipant) =>
|
|
participant.username !== dataProfile?.username
|
|
);
|
|
const hasUnread = Number(thread.unreadCount || "0") > 0;
|
|
|
|
// Seller-inbox only. Shows what state the thread is in so the team
|
|
// can see at a glance which chats need attention.
|
|
// - "needs-reply" → last message is from the customer (or nobody
|
|
// on the team has replied yet).
|
|
// - "unread-by-customer" → team replied, customer hasn't seen it.
|
|
// - "seen" → team replied and the customer opened it.
|
|
const showStatus = type === "store";
|
|
const lastFromTeam = !!thread.lastMessageFromTeam;
|
|
const otherSeen = !!thread.otherPartySeenLast;
|
|
const status: "needs-reply" | "unread-by-customer" | "seen" | null =
|
|
!showStatus
|
|
? null
|
|
: !lastFromTeam
|
|
? "needs-reply"
|
|
: otherSeen
|
|
? "seen"
|
|
: "unread-by-customer";
|
|
|
|
// Generate link based on type
|
|
const linkTo =
|
|
type === "user"
|
|
? `/profile/threads/${thread.id}`
|
|
: `/store/${storeId}/threads/${thread.id}`;
|
|
|
|
return (
|
|
<Link to={linkTo} key={thread.id} className="w-full">
|
|
<div className={`w-full h-[80px] flex items-center px-4 gap-4`}>
|
|
{thread.isCreator ? (
|
|
<SellerLogo
|
|
src={otherParticipant?.storeLogo}
|
|
alt="Participant Avatar"
|
|
/>
|
|
) : (
|
|
<User size={48} className="text-GRAY" />
|
|
)}
|
|
<div className="flex flex-col w-full">
|
|
<div className="w-full flex justify-between items-center">
|
|
<div className="flex items-center gap-1 min-w-0">
|
|
{status === "needs-reply" && (
|
|
<MessageCircleWarning
|
|
size={16}
|
|
className="text-RED shrink-0"
|
|
aria-label="در انتظار پاسخ شما"
|
|
/>
|
|
)}
|
|
{status === "seen" && (
|
|
<CheckCheck
|
|
size={16}
|
|
className="text-blue-500 shrink-0"
|
|
aria-label="پیام دیده شده"
|
|
/>
|
|
)}
|
|
<p className={`text-R14 truncate ${hasUnread || status === "needs-reply" ? "font-bold" : ""}`}>
|
|
{thread.isCreator
|
|
? otherParticipant?.storeName ||
|
|
otherParticipant?.username
|
|
: otherParticipant?.name}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col items-end justify-center">
|
|
{thread.lastMessageTime && (
|
|
<p className="text-R10 text-GRAY">
|
|
{formatThreadTimestamp(thread.lastMessageTime)}
|
|
</p>
|
|
)}
|
|
{hasUnread && (
|
|
<div className="w-5 h-5 flex items-center justify-center bg-blue-500 text-WHITE text-R10 rounded-full">
|
|
{thread.unreadCount}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p
|
|
className={`text-R14 ${hasUnread ? "text-BLACK font-bold" : "text-GRAY"}`}
|
|
>
|
|
{thread.lastMessage?.content ||
|
|
(thread.lastMessage?.imageUrl
|
|
? "📷 تصویر"
|
|
: thread.lastMessage?.product
|
|
? `🛍 ${thread.lastMessage.product.title || "محصول"}`
|
|
: "بدون پیام")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|