AW-496-connect-chats

edits
This commit is contained in:
gofnnp 2025-07-21 22:03:27 +04:00
parent 324e7a9eaa
commit 5b8fc5047d
50 changed files with 724 additions and 198 deletions

View File

@ -294,6 +294,7 @@
}, },
"new_messages": "Новые сообщения", "new_messages": "Новые сообщения",
"view_all": "View All ({count})", "view_all": "View All ({count})",
"hide_all": "Hide",
"typing": "is typing...", "typing": "is typing...",
"voice_message": "Voice message", "voice_message": "Voice message",
"photo": "Photo", "photo": "Photo",

View File

@ -4,13 +4,14 @@ import {
ChatModalsWrapper, ChatModalsWrapper,
MessageInputWrapper, MessageInputWrapper,
} from "@/components/domains/chat"; } from "@/components/domains/chat";
import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./page.module.scss"; import styles from "./page.module.scss";
export default function Chat() { export default function Chat() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<ChatHeader /> <ChatHeader chatsPromise={loadChatsList()} />
<ChatMessagesWrapper /> <ChatMessagesWrapper />
<MessageInputWrapper /> <MessageInputWrapper />
<ChatModalsWrapper /> <ChatModalsWrapper />

View File

@ -10,32 +10,32 @@ import {
NewMessagesWrapperSkeleton, NewMessagesWrapperSkeleton,
} from "@/components/domains/chat"; } from "@/components/domains/chat";
import { NavigationBar } from "@/components/layout"; import { NavigationBar } from "@/components/layout";
import { import { loadChatsList } from "@/entities/chats/loaders";
loadCategorizedChats,
loadCorrespondenceStarted,
loadUnreadChats,
} from "@/entities/chats/loaders";
import styles from "./page.module.scss"; import styles from "./page.module.scss";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const fetchCache = "force-no-store";
export default function Chats() { export default function Chats() {
const chatsPromise = loadChatsList();
return ( return (
<div className={styles.container}> <div className={styles.container}>
<ChatListHeader /> <ChatListHeader />
<section className={styles.categories}> <section className={styles.categories}>
<Suspense fallback={<NewMessagesWrapperSkeleton />}> <Suspense fallback={<NewMessagesWrapperSkeleton />}>
<NewMessagesWrapper chatsPromise={loadUnreadChats()} /> <NewMessagesWrapper chatsPromise={chatsPromise} />
</Suspense> </Suspense>
<Suspense fallback={<CorrespondenceStartedSkeleton />}> <Suspense fallback={<CorrespondenceStartedSkeleton />}>
<CorrespondenceStartedWrapper <CorrespondenceStartedWrapper chatsPromise={chatsPromise} />
chatsPromise={loadCorrespondenceStarted()}
/>
</Suspense> </Suspense>
<Suspense fallback={<ChatCategoriesSkeleton />}> <Suspense fallback={<ChatCategoriesSkeleton />}>
<ChatCategories chatsPromise={loadCategorizedChats()} /> <ChatCategories chatsPromise={chatsPromise} />
</Suspense> </Suspense>
</section> </section>
<NavigationBar /> <NavigationBar chatsPromise={chatsPromise} />
</div> </div>
); );
} }

View File

@ -4,12 +4,17 @@ import {
AdvisersSection, AdvisersSection,
AdvisersSectionSkeleton, AdvisersSectionSkeleton,
} from "@/components/domains/dashboard"; } from "@/components/domains/dashboard";
import { loadChatsList } from "@/entities/chats/loaders";
import { loadAssistants } from "@/entities/dashboard/loaders"; import { loadAssistants } from "@/entities/dashboard/loaders";
export default function Advisers() { export default function Advisers() {
return ( return (
<Suspense fallback={<AdvisersSectionSkeleton />}> <Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} gridDisplayMode="vertical" /> <AdvisersSection
promiseAssistants={loadAssistants()}
promiseChats={loadChatsList()}
gridDisplayMode="vertical"
/>
</Suspense> </Suspense>
); );
} }

View File

@ -1,4 +1,6 @@
import { DrawerProvider, Header, NavigationBar } from "@/components/layout"; import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
import { loadChatsList } from "@/entities/chats/loaders";
import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss"; import styles from "./layout.module.scss";
@ -7,11 +9,14 @@ export default function CoreLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const chatsPromise = loadChatsList();
return ( return (
<DrawerProvider> <DrawerProvider>
<Header className={styles.navBar} /> <ChatStoreProvider>
<main className={styles.main}>{children}</main> <Header className={styles.navBar} chatsPromise={chatsPromise} />
<NavigationBar /> <main className={styles.main}>{children}</main>
<NavigationBar chatsPromise={chatsPromise} />
</ChatStoreProvider>
</DrawerProvider> </DrawerProvider>
); );
} }

View File

@ -7,10 +7,13 @@ import {
CompatibilitySectionSkeleton, CompatibilitySectionSkeleton,
MeditationSection, MeditationSection,
MeditationSectionSkeleton, MeditationSectionSkeleton,
NewMessagesSection,
NewMessagesSectionSkeleton,
PalmSection, PalmSection,
PalmSectionSkeleton, PalmSectionSkeleton,
} from "@/components/domains/dashboard"; } from "@/components/domains/dashboard";
import { Horoscope } from "@/components/widgets"; import { Horoscope } from "@/components/widgets";
import { loadChatsList } from "@/entities/chats/loaders";
import { import {
loadAssistants, loadAssistants,
loadCompatibility, loadCompatibility,
@ -21,12 +24,20 @@ import {
import styles from "./page.module.scss"; import styles from "./page.module.scss";
export default function Home() { export default function Home() {
const chatsPromise = loadChatsList();
return ( return (
<section className={styles.page}> <section className={styles.page}>
<Suspense fallback={<NewMessagesSectionSkeleton />}>
<NewMessagesSection chatsPromise={chatsPromise} />
</Suspense>
<Horoscope /> <Horoscope />
<Suspense fallback={<AdvisersSectionSkeleton />}> <Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} /> <AdvisersSection
promiseAssistants={loadAssistants()}
promiseChats={chatsPromise}
/>
</Suspense> </Suspense>
<Suspense fallback={<CompatibilitySectionSkeleton />}> <Suspense fallback={<CompatibilitySectionSkeleton />}>

View File

@ -1,4 +1,5 @@
import { DrawerProvider, Header } from "@/components/layout"; import { DrawerProvider, Header } from "@/components/layout";
import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./layout.module.scss"; import styles from "./layout.module.scss";
@ -9,7 +10,7 @@ export default function CoreLayout({
}>) { }>) {
return ( return (
<DrawerProvider> <DrawerProvider>
<Header className={styles.navBar} /> <Header className={styles.navBar} chatsPromise={loadChatsList()} />
<main className={styles.main}>{children}</main> <main className={styles.main}>{children}</main>
</DrawerProvider> </DrawerProvider>
); );

View File

@ -74,6 +74,7 @@ export async function GET(req: NextRequest) {
`${nextUrl || ROUTES.payment()}`, `${nextUrl || ROUTES.payment()}`,
process.env.NEXT_PUBLIC_APP_URL || "" process.env.NEXT_PUBLIC_APP_URL || ""
); );
if (productId) redirectUrl.searchParams.set("productId", productId); if (productId) redirectUrl.searchParams.set("productId", productId);
if (placementId) redirectUrl.searchParams.set("placementId", placementId); if (placementId) redirectUrl.searchParams.set("placementId", placementId);
if (paywallId) redirectUrl.searchParams.set("paywallId", paywallId); if (paywallId) redirectUrl.searchParams.set("paywallId", paywallId);

View File

@ -2,7 +2,7 @@ import "@/styles/reset.css";
import "@/styles/globals.css"; import "@/styles/globals.css";
import "react-circular-progressbar/dist/styles.css"; import "react-circular-progressbar/dist/styles.css";
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { hasLocale, NextIntlClientProvider } from "next-intl"; import { hasLocale, NextIntlClientProvider } from "next-intl";
@ -11,6 +11,7 @@ import clsx from "clsx";
import { loadUser, loadUserId } from "@/entities/user/loaders"; import { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { AppUiStoreProvider } from "@/providers/app-ui-store-provider";
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider"; import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
import { RetainingStoreProvider } from "@/providers/retaining-store-provider"; import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
import SocketProvider from "@/providers/socket-provider"; import SocketProvider from "@/providers/socket-provider";
@ -29,6 +30,13 @@ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
}); });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: "WIT", title: "WIT",
description: description:
@ -60,7 +68,9 @@ export default async function RootLayout({
<SocketProvider userId={userId}> <SocketProvider userId={userId}>
<RetainingStoreProvider> <RetainingStoreProvider>
<ChatsInitializationProvider> <ChatsInitializationProvider>
<ToastProvider maxVisible={3}>{children}</ToastProvider> <ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsInitializationProvider> </ChatsInitializationProvider>
</RetainingStoreProvider> </RetainingStoreProvider>
</SocketProvider> </SocketProvider>

View File

@ -31,7 +31,7 @@ export default function CategoryChats({
userAvatar={{ userAvatar={{
src: chat.assistantAvatar, src: chat.assistantAvatar,
alt: chat.assistantName, alt: chat.assistantName,
isOnline: true, isOnline: chat.status === "active",
}} }}
name={chat.assistantName} name={chat.assistantName}
messagePreiew={ messagePreiew={

View File

@ -4,31 +4,36 @@ import { use, useState } from "react";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { Chips } from "@/components/widgets"; import { Chips } from "@/components/widgets";
import { ICategorizedChats } from "@/entities/chats/types"; import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { CategoryChats, ChatItemsList } from ".."; import { CategoryChats, ChatItemsList } from "..";
const MAX_HIDE_VISIBLE_COUNT = 3;
interface ChatCategoriesProps { interface ChatCategoriesProps {
chatsPromise: Promise<ICategorizedChats>; chatsPromise: Promise<IGetChatsListResponse>;
} }
export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) { export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
const chats = use(chatsPromise); const chats = use(chatsPromise);
const { categorizedChats } = useChatsSocket({ initialChats: chats });
const [activeChip, setActiveChip] = useState<string>("All"); const [activeChip, setActiveChip] = useState<string>("All");
const [maxVisibleChats, setMaxVisibleChats] = useState< const [maxVisibleChats, setMaxVisibleChats] = useState<
Partial<Record<string, number | null>> Partial<Record<string, number | null>>
>({}); >({});
const chips = Object.keys(chats).map(key => ({ const chips = Object.keys(categorizedChats).map(key => ({
text: key, text: key,
})); }));
chips.unshift({ chips.unshift({
text: "All", text: "All",
}); });
const filteredChats = Object.keys(chats).filter(key => { const filteredChats = Object.keys(categorizedChats).filter(key => {
if (activeChip === "All") return true; if (activeChip === "All") return true;
return chats[key].some(chat => chat.category === activeChip); return categorizedChats[key].some(chat => chat.category === activeChip);
}); });
return ( return (
@ -44,18 +49,22 @@ export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
title={key} title={key}
key={key} key={key}
viewAllProps={{ viewAllProps={{
count: chats[key].length, count: categorizedChats[key].length,
isAll: !!maxVisibleChats[key],
onClick: () => { onClick: () => {
setMaxVisibleChats(prev => ({ setMaxVisibleChats(prev => ({
...prev, ...prev,
[key]: !!prev[key] ? null : chats[key].length, [key]: !!prev[key] ? null : categorizedChats[key].length,
})); }));
}, },
}} }}
isVisibleViewAll={
categorizedChats[key].length > MAX_HIDE_VISIBLE_COUNT
}
> >
<CategoryChats <CategoryChats
chats={chats[key]} chats={categorizedChats[key]}
maxVisibleChats={maxVisibleChats[key] ?? 3} maxVisibleChats={maxVisibleChats[key] ?? MAX_HIDE_VISIBLE_COUNT}
/> />
</ChatItemsList> </ChatItemsList>
))} ))}

View File

@ -1,11 +1,20 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { use, useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Icon, IconName, OnlineIndicator, Typography } from "@/components/ui"; import {
Badge,
Icon,
IconName,
OnlineIndicator,
Typography,
} from "@/components/ui";
import { revalidateChatsPage } from "@/entities/chats/actions";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChat } from "@/providers/chat-provider"; import { useChat } from "@/providers/chat-provider";
import { useChatStore } from "@/providers/chat-store-provider"; import { useChatStore } from "@/providers/chat-store-provider";
import { formatSecondsToHHMMSS } from "@/shared/utils/date"; import { formatSecondsToHHMMSS } from "@/shared/utils/date";
@ -13,12 +22,17 @@ import { delay } from "@/shared/utils/delay";
import styles from "./ChatHeader.module.scss"; import styles from "./ChatHeader.module.scss";
export default function ChatHeader() { interface ChatHeaderProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function ChatHeader({ chatsPromise }: ChatHeaderProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const router = useRouter(); const router = useRouter();
const currentChat = useChatStore(state => state.currentChat); const currentChat = useChatStore(state => state.currentChat);
const { isLoadingAdvisorMessage, isAvailableChatting } = useChat(); const { isLoadingAdvisorMessage, isAvailableChatting } = useChat();
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const [timer, setTimer] = useState(0); const [timer, setTimer] = useState(0);
useEffect(() => { useEffect(() => {
@ -30,19 +44,26 @@ export default function ChatHeader() {
})(); })();
}, [isAvailableChatting, timer]); }, [isAvailableChatting, timer]);
const handleBack = async () => {
await revalidateChatsPage();
router.back();
};
return ( return (
<header className={styles.header}> <header className={styles.header}>
<div className={styles.back} onClick={() => router.back()}> <div className={styles.back} onClick={handleBack}>
<Icon <Icon
name={IconName.ChevronLeft} name={IconName.ChevronLeft}
size={{ height: 22, width: 22 }} size={{ height: 22, width: 22 }}
color="#374151" color="#374151"
/> />
{/* <Badge className={styles.badge}> {!!totalUnreadCount && (
<Typography weight="semiBold" size="xs" color="black"> <Badge className={styles.badge}>
2 <Typography weight="semiBold" size="xs" color="black">
</Typography> {totalUnreadCount}
</Badge> */} </Typography>
</Badge>
)}
</div> </div>
<div className={styles.chatInfo}> <div className={styles.chatInfo}>
{!!currentChat?.assistantAvatar ? ( {!!currentChat?.assistantAvatar ? (
@ -60,7 +81,7 @@ export default function ChatHeader() {
<Typography weight="semiBold" className={styles.name}> <Typography weight="semiBold" className={styles.name}>
{currentChat?.assistantName} {currentChat?.assistantName}
<OnlineIndicator <OnlineIndicator
isOnline={currentChat?.status === "inactive"} isOnline={currentChat?.status === "active"}
className={styles.onlineIndicator} className={styles.onlineIndicator}
/> />
</Typography> </Typography>

View File

@ -11,6 +11,7 @@ interface ChatItemsListProps {
children: React.ReactNode; children: React.ReactNode;
title: string; title: string;
viewAllProps: ViewAllProps; viewAllProps: ViewAllProps;
isVisibleViewAll?: boolean;
} }
export default function ChatItemsList({ export default function ChatItemsList({
@ -18,10 +19,15 @@ export default function ChatItemsList({
children, children,
title, title,
viewAllProps, viewAllProps,
isVisibleViewAll = true,
}: ChatItemsListProps) { }: ChatItemsListProps) {
return ( return (
<div className={clsx(styles.chatItemsList, className)}> <div className={clsx(styles.chatItemsList, className)}>
<ChatItemsListHeader title={title} viewAllProps={viewAllProps} /> <ChatItemsListHeader
title={title}
viewAllProps={viewAllProps}
isVisibleViewAll={isVisibleViewAll}
/>
{children} {children}
</div> </div>
); );

View File

@ -7,18 +7,20 @@ import styles from "./ChatItemsListHeader.module.scss";
interface ChatItemsListHeaderProps { interface ChatItemsListHeaderProps {
title: string; title: string;
viewAllProps: ViewAllProps; viewAllProps: ViewAllProps;
isVisibleViewAll?: boolean;
} }
export default function ChatItemsListHeader({ export default function ChatItemsListHeader({
title, title,
viewAllProps, viewAllProps,
isVisibleViewAll = true,
}: ChatItemsListHeaderProps) { }: ChatItemsListHeaderProps) {
return ( return (
<div className={styles.chatItemsListHeader}> <div className={styles.chatItemsListHeader}>
<Typography className={styles.title} as="h3" size="lg" weight="bold"> <Typography className={styles.title} as="h3" size="lg" weight="bold">
{title} {title}
</Typography> </Typography>
<ViewAll {...viewAllProps} /> {isVisibleViewAll && <ViewAll {...viewAllProps} />}
</div> </div>
); );
} }

View File

@ -33,7 +33,12 @@ export default function ChatMessage({ message }: ChatMessageProps) {
const { read } = useChat(); const { read } = useChat();
useEffect(() => { useEffect(() => {
if (!!message.id && !message.isRead) { if (
!!message.id &&
!message.isRead &&
message.id !== "typing" &&
!message.id.startsWith("sending-message")
) {
read([message.id]); read([message.id]);
} }
}, [message.id, message.isRead, read]); }, [message.id, message.isRead, read]);

View File

@ -5,6 +5,7 @@
padding: 36px 16px; padding: 36px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-start;
flex-direction: column-reverse;
gap: 8px; gap: 8px;
} }

View File

@ -13,9 +13,6 @@ export default function ChatMessages({
}: ChatMessagesProps) { }: ChatMessagesProps) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
{messages.map(message => (
<ChatMessage key={message.id} message={message} />
))}
{isLoadingAdvisorMessage && ( {isLoadingAdvisorMessage && (
<ChatMessage <ChatMessage
message={{ message={{
@ -28,6 +25,9 @@ export default function ChatMessages({
}} }}
/> />
)} )}
{messages.map(message => (
<ChatMessage key={message.id} message={message} />
))}
</div> </div>
); );
} }

View File

@ -7,5 +7,5 @@
.loaderTop { .loaderTop {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 8px 0; padding-top: 16px;
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { useChat } from "@/providers/chat-provider"; import { useChat } from "@/providers/chat-provider";
@ -17,15 +17,20 @@ export default function ChatMessagesWrapper() {
hasMoreOlderMessages, hasMoreOlderMessages,
isLoadingOlder, isLoadingOlder,
messagesWrapperRef, messagesWrapperRef,
loadOlder,
scrollToBottom, scrollToBottom,
} = useChat(); } = useChat();
// const handleScroll = useCallback(() => { const isInitialScrollDone = useRef(false);
// const el = messagesWrapperRef.current;
// if (!el) return;
// if (el.scrollTop < 120) loadOlder(); const handleScroll = useCallback(() => {
// }, [loadOlder]); const el = messagesWrapperRef.current;
if (!el || !isInitialScrollDone.current) return;
if (el.scrollTop < 100) {
loadOlder();
}
}, [loadOlder, messagesWrapperRef]);
const mappedMessages = useMemo(() => { const mappedMessages = useMemo(() => {
const msgs = socketMessages.map(m => ({ const msgs = socketMessages.map(m => ({
@ -40,14 +45,20 @@ export default function ChatMessagesWrapper() {
}, [socketMessages]); }, [socketMessages]);
useEffect(() => { useEffect(() => {
scrollToBottom(); if (socketMessages.length > 0 && !isInitialScrollDone.current) {
}, [scrollToBottom]); scrollToBottom();
const timeout = setTimeout(() => {
isInitialScrollDone.current = true;
}, 1000);
return () => clearTimeout(timeout);
}
}, [socketMessages.length, scrollToBottom]);
return ( return (
<div <div
className={styles.messagesWrapper} className={styles.messagesWrapper}
ref={messagesWrapperRef} ref={messagesWrapperRef}
// onScroll={handleScroll} onScroll={handleScroll}
> >
{isLoadingOlder && hasMoreOlderMessages && ( {isLoadingOlder && hasMoreOlderMessages && (
<div className={styles.loaderTop}> <div className={styles.loaderTop}>

View File

@ -1,4 +1,4 @@
.container.container { .card.card {
padding: 13px 0; padding: 13px 0;
} }
@ -21,3 +21,12 @@
} }
} }
} }
.container.container {
position: relative;
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@ -12,36 +12,47 @@ import { formatTime } from "@/shared/utils/date";
import styles from "./CorrespondenceStarted.module.scss"; import styles from "./CorrespondenceStarted.module.scss";
const getTopPositionItem = (index: number) => {
return Array.from({ length: index }, (_, i) => i).reduce((acc, current) => {
return acc + 11 / 1.5 ** current;
}, 0);
};
interface CorrespondenceStartedProps { interface CorrespondenceStartedProps {
chats: IChat[]; chats: IChat[];
maxVisibleChats?: number; isVisibleAll?: boolean;
maxHideVisibleCount?: number;
} }
export default function CorrespondenceStarted({ export default function CorrespondenceStarted({
chats, chats,
maxVisibleChats = 3, isVisibleAll = false,
maxHideVisibleCount = 3,
}: CorrespondenceStartedProps) { }: CorrespondenceStartedProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations("Chat");
const setCurrentChat = useChatStore(state => state.setCurrentChat); const setCurrentChat = useChatStore(state => state.setCurrentChat);
return ( return (
<Card className={styles.container}> <Container isVisibleAll={isVisibleAll} chats={chats}>
<div className={styles.header}> {chats
<Icon name={IconName.Pin} size={{ height: 17, width: 13.5 }} /> .slice(0, isVisibleAll ? chats.length : maxHideVisibleCount)
<Typography size="sm" color="muted"> .map((chat, index) => (
{t("correspondence_started.pinned_chats")}
</Typography>
</div>
<div className={styles.chats}>
{chats.slice(0, maxVisibleChats).map(chat => (
<ChatItem <ChatItem
key={chat.id} key={chat.id}
className={styles.chat} className={styles.chat}
style={
!isVisibleAll
? {
top: `${getTopPositionItem(index)}px`,
zIndex: 1111 - index,
position: !!index ? "absolute" : "relative",
}
: undefined
}
userAvatar={{ userAvatar={{
src: chat.assistantAvatar, src: chat.assistantAvatar,
alt: chat.assistantName, alt: chat.assistantName,
isOnline: true, isOnline: chat.status === "active",
}} }}
name={chat.assistantName} name={chat.assistantName}
messagePreiew={ messagePreiew={
@ -62,7 +73,43 @@ export default function CorrespondenceStarted({
}} }}
/> />
))} ))}
</div> </Container>
</Card> );
}
function Container({
children,
isVisibleAll,
chats,
}: {
children: React.ReactNode;
isVisibleAll: boolean;
chats: IChat[];
}) {
const t = useTranslations("Chat");
if (isVisibleAll) {
return (
<Card className={styles.card}>
<div className={styles.header}>
<Icon name={IconName.Pin} size={{ height: 17, width: 13.5 }} />
<Typography size="sm" color="muted">
{t("correspondence_started.pinned_chats")}
</Typography>
</div>
<div className={styles.chats}>{children}</div>
</Card>
);
}
return (
<div
className={styles.container}
style={{
paddingBottom: getTopPositionItem(chats.length - 1),
}}
>
{children}
</div>
); );
} }

View File

@ -1,15 +1,17 @@
"use client"; "use client";
import { use, useState } from "react"; import { use } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { IChat } from "@/entities/chats/types"; import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { ChatItemsList, CorrespondenceStarted } from ".."; import { ChatItemsList, CorrespondenceStarted } from "..";
interface CorrespondenceStartedWrapperProps { interface CorrespondenceStartedWrapperProps {
chatsPromise: Promise<IChat[]>; chatsPromise: Promise<IGetChatsListResponse>;
} }
export default function CorrespondenceStartedWrapper({ export default function CorrespondenceStartedWrapper({
@ -17,24 +19,37 @@ export default function CorrespondenceStartedWrapper({
}: CorrespondenceStartedWrapperProps) { }: CorrespondenceStartedWrapperProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const chats = use(chatsPromise); const chats = use(chatsPromise);
const { startedChats } = useChatsSocket({ initialChats: chats });
const [maxVisibleChats, setMaxVisibleChats] = useState<number | null>(null); const { isVisibleAll } = useAppUiStore(
state => state.chats.correspondenceStarted
);
const hasHydrated = useAppUiStore(state => state._hasHydrated);
const setChatsCorrespondenceStarted = useAppUiStore(
state => state.setChatsCorrespondenceStarted
);
if (!hasHydrated) return <CorrespondenceStartedSkeleton />;
return ( return (
<> <>
{!!chats.length && ( {!!startedChats.length && (
<ChatItemsList <ChatItemsList
title={t("correspondence_started.title")} title={t("correspondence_started.title")}
viewAllProps={{ viewAllProps={{
count: chats.length, count: startedChats.length,
isAll: isVisibleAll,
onClick: () => { onClick: () => {
setMaxVisibleChats(prev => (prev ? null : chats.length)); setChatsCorrespondenceStarted({
isVisibleAll: !isVisibleAll,
});
}, },
}} }}
isVisibleViewAll={startedChats.length > 1}
> >
<CorrespondenceStarted <CorrespondenceStarted
chats={chats} chats={startedChats}
maxVisibleChats={maxVisibleChats ?? 3} isVisibleAll={isVisibleAll}
/> />
</ChatItemsList> </ChatItemsList>
)} )}

View File

@ -1,4 +1,4 @@
.container { .container.container {
position: relative; position: relative;
width: 100%; width: 100%;
height: fit-content; height: fit-content;
@ -11,3 +11,18 @@
left: 0; left: 0;
width: 100%; width: 100%;
} }
.card.card {
padding: 13px 0;
& > .newMessage {
background-color: transparent;
border: none;
box-shadow: none;
border-bottom: 1px solid #f3f4f6;
&:last-child {
border-bottom: none;
}
}
}

View File

@ -2,6 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Card } from "@/components/ui";
import { ChatItem } from "@/components/widgets"; import { ChatItem } from "@/components/widgets";
import { IChat } from "@/entities/chats/types"; import { IChat } from "@/entities/chats/types";
import { useChatStore } from "@/providers/chat-store-provider"; import { useChatStore } from "@/providers/chat-store-provider";
@ -19,14 +20,74 @@ const getTopPositionItem = (index: number) => {
interface NewMessagesProps { interface NewMessagesProps {
chats: IChat[]; chats: IChat[];
isVisibleAll: boolean; isVisibleAll: boolean;
maxHideVisibleCount?: number;
} }
export default function NewMessages({ export default function NewMessages({
chats, chats,
isVisibleAll = false, isVisibleAll = false,
maxHideVisibleCount = 3,
}: NewMessagesProps) { }: NewMessagesProps) {
const router = useRouter(); const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat); const setCurrentChat = useChatStore(state => state.setCurrentChat);
return (
<Container isVisibleAll={isVisibleAll} chats={chats}>
{chats
.slice(0, isVisibleAll ? chats.length : maxHideVisibleCount)
.map((chat, index) => (
<ChatItem
key={chat.id}
className={styles.newMessage}
style={
!isVisibleAll
? {
top: `${getTopPositionItem(index)}px`,
zIndex: 1111 - index,
position: !!index ? "absolute" : "relative",
}
: undefined
}
userAvatar={{
src: chat.assistantAvatar,
alt: chat.assistantName,
isOnline: chat.status === "active",
}}
name={chat.assistantName}
messagePreiew={
chat.lastMessage
? {
message: {
type: chat.lastMessage.type,
content: chat.lastMessage.text,
},
}
: null
}
time={formatTime(chat.updatedAt)}
badgeContent={chat.unreadCount}
onClick={() => {
setCurrentChat(chat);
router.push(ROUTES.chat(chat.assistantId));
}}
/>
))}
</Container>
);
}
function Container({
children,
isVisibleAll,
chats,
}: {
children: React.ReactNode;
isVisibleAll: boolean;
chats: IChat[];
}) {
if (isVisibleAll) {
return <Card className={styles.card}>{children}</Card>;
}
return ( return (
<div <div
className={styles.container} className={styles.container}
@ -34,43 +95,7 @@ export default function NewMessages({
paddingBottom: getTopPositionItem(chats.length - 1), paddingBottom: getTopPositionItem(chats.length - 1),
}} }}
> >
{chats.map((chat, index) => ( {children}
<ChatItem
key={chat.id}
className={styles.newMessage}
style={
!isVisibleAll
? {
top: `${getTopPositionItem(index)}px`,
zIndex: 1111 - index,
position: !!index ? "absolute" : "relative",
}
: undefined
}
userAvatar={{
src: chat.assistantAvatar,
alt: chat.assistantName,
isOnline: true,
}}
name={chat.assistantName}
messagePreiew={
chat.lastMessage
? {
message: {
type: chat.lastMessage.type,
content: chat.lastMessage.text,
},
}
: null
}
time={formatTime(chat.updatedAt)}
badgeContent={chat.unreadCount}
onClick={() => {
setCurrentChat(chat);
router.push(ROUTES.chat(chat.assistantId));
}}
/>
))}
</div> </div>
); );
} }

View File

@ -1,15 +1,17 @@
"use client"; "use client";
import { use, useState } from "react"; import { use } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { IChat } from "@/entities/chats/types"; import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { ChatItemsList, NewMessages } from ".."; import { ChatItemsList, NewMessages } from "..";
interface NewMessagesWrapperProps { interface NewMessagesWrapperProps {
chatsPromise: Promise<IChat[]>; chatsPromise: Promise<IGetChatsListResponse>;
} }
export default function NewMessagesWrapper({ export default function NewMessagesWrapper({
@ -17,22 +19,29 @@ export default function NewMessagesWrapper({
}: NewMessagesWrapperProps) { }: NewMessagesWrapperProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const chats = use(chatsPromise); const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
const [isVisibleAll, setIsVisibleAll] = useState<boolean>(false); const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);
const setChatsNewMessages = useAppUiStore(state => state.setChatsNewMessages);
if (!hasHydrated) return <NewMessagesWrapperSkeleton />;
return ( return (
<> <>
{!!chats.length && ( {!!unreadChats.length && (
<ChatItemsList <ChatItemsList
title={t("new_messages")} title={t("new_messages")}
viewAllProps={{ viewAllProps={{
count: chats.length, count: unreadChats.length,
isAll: isVisibleAll,
onClick: () => { onClick: () => {
setIsVisibleAll(prev => !prev); setChatsNewMessages({ isVisibleAll: !isVisibleAll });
}, },
}} }}
isVisibleViewAll={unreadChats.length > 1}
> >
<NewMessages chats={chats} isVisibleAll={isVisibleAll} /> <NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} />
</ChatItemsList> </ChatItemsList>
)} )}
</> </>

View File

@ -7,19 +7,18 @@ import { Button, Typography } from "@/components/ui";
import styles from "./ViewAll.module.scss"; import styles from "./ViewAll.module.scss";
export interface ViewAllProps { export interface ViewAllProps {
isAll: boolean;
count: number; count: number;
onClick?: () => void; onClick?: () => void;
} }
export default function ViewAll({ count, onClick }: ViewAllProps) { export default function ViewAll({ count, isAll, onClick }: ViewAllProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
return ( return (
<Button className={styles.viewAllButton} onClick={onClick}> <Button className={styles.viewAllButton} onClick={onClick}>
<Typography size="sm" weight="medium" color="muted"> <Typography size="sm" weight="medium" color="muted">
{t("view_all", { {isAll ? t("hide_all") : t("view_all", { count })}
count,
})}
</Typography> </Typography>
</Button> </Button>
); );

View File

@ -1,21 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Card, Stars, Typography } from "@/components/ui"; import { Button, Card, Stars, Typography } from "@/components/ui";
import { IChat } from "@/entities/chats/types";
import { Assistant } from "@/entities/dashboard/types"; import { Assistant } from "@/entities/dashboard/types";
import { useChatStore } from "@/providers/chat-store-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./AdviserCard.module.scss"; import styles from "./AdviserCard.module.scss";
type AdviserCardProps = Assistant; type AdviserCardProps = {
assistant: Assistant;
chat: IChat | null;
};
export default function AdviserCard({ assistant, chat }: AdviserCardProps) {
const router = useRouter();
const { _id, name, photoUrl, rating, reviewCount, description } = assistant;
const setCurrentChat = useChatStore(state => state.setCurrentChat);
export default function AdviserCard({
name,
photoUrl,
rating,
reviewCount,
description,
}: AdviserCardProps) {
return ( return (
<Card <Card
className={styles.card} className={styles.card}
style={{ backgroundImage: `url(${photoUrl})` }} style={{ backgroundImage: `url(${photoUrl})` }}
onClick={() => {
if (chat) {
setCurrentChat(chat);
}
router.push(ROUTES.chat(_id));
}}
> >
<div className={styles.content}> <div className={styles.content}>
<div className={styles.info}> <div className={styles.info}>

View File

@ -2,6 +2,7 @@ import { use } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Grid, Section, Skeleton } from "@/components/ui"; import { Grid, Section, Skeleton } from "@/components/ui";
import { IChat, IGetChatsListResponse } from "@/entities/chats/types";
import { Assistant } from "@/entities/dashboard/types"; import { Assistant } from "@/entities/dashboard/types";
import { AdviserCard } from "../../cards"; import { AdviserCard } from "../../cards";
@ -9,15 +10,22 @@ import { AdviserCard } from "../../cards";
import styles from "./AdvisersSection.module.scss"; import styles from "./AdvisersSection.module.scss";
interface AdvisersSectionProps { interface AdvisersSectionProps {
promise: Promise<Assistant[]>; promiseAssistants: Promise<Assistant[]>;
promiseChats: Promise<IGetChatsListResponse>;
gridDisplayMode?: "vertical" | "horizontal"; gridDisplayMode?: "vertical" | "horizontal";
} }
const getChatByAssistantId = (assistantId: string, chats: IChat[]) => {
return chats.find(chat => chat.assistantId === assistantId) || null;
};
export default function AdvisersSection({ export default function AdvisersSection({
promise, promiseAssistants,
promiseChats,
gridDisplayMode = "horizontal", gridDisplayMode = "horizontal",
}: AdvisersSectionProps) { }: AdvisersSectionProps) {
const assistants = use(promise); const assistants = use(promiseAssistants);
const chats = use(promiseChats);
const columns = Math.ceil(assistants?.length / 2); const columns = Math.ceil(assistants?.length / 2);
return ( return (
@ -27,7 +35,14 @@ export default function AdvisersSection({
className={clsx(styles.grid, styles[gridDisplayMode])} className={clsx(styles.grid, styles[gridDisplayMode])}
> >
{assistants.map(adviser => ( {assistants.map(adviser => (
<AdviserCard key={adviser._id} {...adviser} /> <AdviserCard
key={adviser._id}
assistant={adviser}
chat={getChatByAssistantId(
adviser._id,
chats.categorizedChats[adviser.category]
)}
/>
))} ))}
</Grid> </Grid>
</Section> </Section>

View File

@ -0,0 +1,6 @@
.container {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 16px;
}

View File

@ -0,0 +1,51 @@
"use client";
import { use } from "react";
import { NewMessages, ViewAll } from "@/components/domains/chat";
import { Skeleton } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
import styles from "./NewMessagesSection.module.scss";
interface NewMessagesSectionProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NewMessagesSection({
chatsPromise,
}: NewMessagesSectionProps) {
const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);
const setHomeNewMessages = useAppUiStore(state => state.setHomeNewMessages);
if (!hasHydrated) return <NewMessagesSectionSkeleton />;
return (
<>
{!!unreadChats.length && (
<div className={styles.container}>
{unreadChats.length > 1 && (
<ViewAll
count={unreadChats.length}
isAll={isVisibleAll}
onClick={() => {
setHomeNewMessages({ isVisibleAll: !isVisibleAll });
}}
/>
)}
<NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} />
</div>
)}
</>
);
}
export const NewMessagesSectionSkeleton = () => {
return <Skeleton style={{ height: 100, width: "100%" }} />;
};

View File

@ -10,6 +10,10 @@ export {
default as MeditationSection, default as MeditationSection,
MeditationSectionSkeleton, MeditationSectionSkeleton,
} from "./MeditationSection/MeditationSection"; } from "./MeditationSection/MeditationSection";
export {
default as NewMessagesSection,
NewMessagesSectionSkeleton,
} from "./NewMessagesSection/NewMessagesSection";
export { export {
default as PalmSection, default as PalmSection,
PalmSectionSkeleton, PalmSectionSkeleton,

View File

@ -1,7 +1,7 @@
.container { .container {
width: 100%; width: 100%;
display: flex; display: flex;
align-items: start; align-items: flex-start;
gap: 4px; gap: 4px;
} }

View File

@ -1,9 +1,12 @@
"use client"; "use client";
import { use } from "react";
import Link from "next/link"; import Link from "next/link";
import clsx from "clsx"; import clsx from "clsx";
import { Button, Icon, IconName } from "@/components/ui"; import { Button, Icon, IconName } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { useDrawer } from ".."; import { useDrawer } from "..";
@ -13,10 +16,13 @@ import styles from "./Header.module.scss";
interface HeaderProps { interface HeaderProps {
className?: string; className?: string;
chatsPromise: Promise<IGetChatsListResponse>;
} }
export default function Header({ className }: HeaderProps) { export default function Header({ className, chatsPromise }: HeaderProps) {
const { open } = useDrawer(); const { open } = useDrawer();
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
return ( return (
<header className={clsx(styles.header, className)}> <header className={clsx(styles.header, className)}>
@ -27,7 +33,9 @@ export default function Header({ className }: HeaderProps) {
<Logo /> <Logo />
</Link> </Link>
<div> <div>
<Icon name={IconName.Notification} /> <Link href={ROUTES.chat()}>
<Icon name={IconName.Notification} iconChildren={totalUnreadCount} />
</Link>
<Icon name={IconName.Search} /> <Icon name={IconName.Search} />
</div> </div>
</header> </header>

View File

@ -1,21 +1,35 @@
"use client"; "use client";
import { use } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import clsx from "clsx"; import clsx from "clsx";
import { Badge, Icon, Typography } from "@/components/ui"; import { Badge, Icon, Typography } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { navItems } from "@/shared/constants/navigation"; import { NavItem, navItems } from "@/shared/constants/navigation";
import { stripLocale } from "@/shared/utils/path"; import { stripLocale } from "@/shared/utils/path";
import styles from "./NavigationBar.module.scss"; import styles from "./NavigationBar.module.scss";
export default function NavigationBar() { const getBadge = (item: NavItem, totalUnreadCount: number) => {
if (item.badgeId === "unreadCount") return totalUnreadCount;
return null;
};
interface NavigationBarProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NavigationBar({ chatsPromise }: NavigationBarProps) {
const pathname = usePathname(); const pathname = usePathname();
const locale = useLocale(); const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale); const pathnameWithoutLocale = stripLocale(pathname, locale);
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
return ( return (
<nav className={styles.container}> <nav className={styles.container}>
@ -25,6 +39,8 @@ export default function NavigationBar() {
? pathnameWithoutLocale === item.href ? pathnameWithoutLocale === item.href
: pathnameWithoutLocale.startsWith(item.href); : pathnameWithoutLocale.startsWith(item.href);
const badge = getBadge(item, totalUnreadCount);
return ( return (
<Link <Link
key={item.key} key={item.key}
@ -32,10 +48,10 @@ export default function NavigationBar() {
className={clsx(styles.item, { [styles.active]: isActive })} className={clsx(styles.item, { [styles.active]: isActive })}
> >
<Icon name={item.icon} color={isActive ? "#007AFF" : "#8A8D93"}> <Icon name={item.icon} color={isActive ? "#007AFF" : "#8A8D93"}>
{item.badge && ( {!!badge && (
<Badge className={styles.badge}> <Badge className={styles.badge}>
<Typography weight="medium" size="xs" color="white"> <Typography weight="medium" size="xs" color="white">
{item.badge} {badge}
</Typography> </Typography>
</Badge> </Badge>
)} )}

View File

@ -94,6 +94,7 @@ export type IconProps = {
cursor?: "pointer" | "auto"; cursor?: "pointer" | "auto";
iconStyle?: CSSProperties; iconStyle?: CSSProperties;
style?: CSSProperties; style?: CSSProperties;
iconChildren?: ReactNode;
}; };
export default function Icon({ export default function Icon({
@ -107,6 +108,7 @@ export default function Icon({
children, children,
cursor = "pointer", cursor = "pointer",
style, style,
iconChildren,
...rest ...rest
}: IconProps) { }: IconProps) {
const Component = icons[name]; const Component = icons[name];
@ -132,7 +134,9 @@ export default function Icon({
display: "block", display: "block",
...rest.iconStyle, ...rest.iconStyle,
}} }}
/> >
{iconChildren}
</Component>
{children} {children}
</span> </span>
); );

View File

@ -26,10 +26,9 @@ export default function NotificationIcon(props: SVGProps<SVGSVGElement>) {
d="M14.75 0C19.1683 0 22.75 3.58172 22.75 8C22.75 12.4183 19.1683 16 14.75 16C10.3317 16 6.75 12.4183 6.75 8C6.75 3.58172 10.3317 0 14.75 0Z" d="M14.75 0C19.1683 0 22.75 3.58172 22.75 8C22.75 12.4183 19.1683 16 14.75 16C10.3317 16 6.75 12.4183 6.75 8C6.75 3.58172 10.3317 0 14.75 0Z"
stroke="#E5E7EB" stroke="#E5E7EB"
/> />
<path <text x="12" y="12" fill="white" fontSize="10">
d="M14.7766 12.0994C14.3079 12.0994 13.89 12.0189 13.5231 11.858C13.1585 11.697 12.8685 11.4732 12.6531 11.1868C12.44 10.898 12.324 10.563 12.305 10.1818H13.1999C13.2189 10.4162 13.2994 10.6186 13.4414 10.7891C13.5835 10.9571 13.7693 11.0874 13.9989 11.1797C14.2286 11.272 14.4831 11.3182 14.7624 11.3182C15.0749 11.3182 15.3519 11.2637 15.5934 11.1548C15.8349 11.0459 16.0243 10.8944 16.1616 10.7003C16.2989 10.5062 16.3675 10.2812 16.3675 10.0256C16.3675 9.75805 16.3013 9.52249 16.1687 9.31889C16.0361 9.11293 15.842 8.95194 15.5863 8.83594C15.3306 8.71993 15.0181 8.66193 14.6488 8.66193H14.0664V7.88068H14.6488C14.9376 7.88068 15.1909 7.8286 15.4087 7.72443C15.6289 7.62026 15.8005 7.47348 15.9237 7.28409C16.0491 7.0947 16.1119 6.87216 16.1119 6.61648C16.1119 6.37026 16.0574 6.15601 15.9485 5.97372C15.8396 5.79143 15.6857 5.64938 15.4869 5.54759C15.2904 5.44579 15.0584 5.39489 14.7908 5.39489C14.5399 5.39489 14.3031 5.44105 14.0806 5.53338C13.8604 5.62334 13.6805 5.75473 13.5408 5.92756C13.4012 6.09801 13.3254 6.30398 13.3136 6.54545H12.4613C12.4755 6.1643 12.5903 5.83049 12.8058 5.54403C13.0212 5.25521 13.3029 5.0303 13.6509 4.86932C14.0013 4.70833 14.386 4.62784 14.805 4.62784C15.2549 4.62784 15.6407 4.71899 15.9627 4.90128C16.2847 5.0812 16.5321 5.31913 16.7049 5.61506C16.8777 5.91098 16.9641 6.23059 16.9641 6.57386C16.9641 6.98343 16.8564 7.33262 16.641 7.62145C16.4279 7.91027 16.1379 8.11032 15.771 8.22159V8.27841C16.2302 8.35417 16.5889 8.54948 16.8469 8.86435C17.105 9.17685 17.234 9.56392 17.234 10.0256C17.234 10.4209 17.1263 10.776 16.9109 11.0909C16.6978 11.4034 16.4066 11.6496 16.0373 11.8295C15.668 12.0095 15.2478 12.0994 14.7766 12.0994Z" {props.children}
fill="white" </text>
/>
<defs> <defs>
<clipPath id="clip0_20_1490"> <clipPath id="clip0_20_1490">
<rect <rect

View File

@ -1,9 +1,16 @@
.onlineIndicator { .onlineIndicator {
aspect-ratio: 1/1; aspect-ratio: 1/1;
border-radius: 50%; border-radius: 50%;
background-color: #10b981;
border: 2px solid #fff; border: 2px solid #fff;
&.online {
background-color: #10b981;
}
&.offline {
background-color: #9ca3af;
}
&.sm { &.sm {
width: 8px; width: 8px;
} }

View File

@ -15,10 +15,12 @@ export default function OnlineIndicator({
}: OnlineIndicatorProps) { }: OnlineIndicatorProps) {
return ( return (
<div <div
className={clsx(styles.onlineIndicator, styles[size], className)} className={clsx(
style={{ styles.onlineIndicator,
backgroundColor: isOnline ? "#10b981" : "#9CA3AF", styles[size],
}} isOnline ? styles.online : styles.offline,
className
)}
/> />
); );
} }

View File

@ -8,11 +8,12 @@
background: #f3f4f6; background: #f3f4f6;
font-size: 14px; font-size: 14px;
overflow-y: auto; overflow-y: auto;
border: 1px solid transparent;
&:active, &:active,
&:focus, &:focus,
&:focus-visible { &:focus-visible {
outline: 1px solid #191f29; border: 1px solid #191f29;
} }
&::placeholder { &::placeholder {

View File

@ -4,6 +4,7 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-height: 94px; min-height: 94px;
width: 100%;
.content { .content {
display: grid; display: grid;

View File

@ -1,5 +1,7 @@
"use server"; "use server";
import { revalidateTag } from "next/cache";
import { http } from "@/shared/api/httpClient"; import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes"; import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/types"; import { ActionResponse } from "@/types";
@ -57,3 +59,7 @@ export async function fetchChatMessages(
return { data: null, error: errorMessage }; return { data: null, error: errorMessage };
} }
} }
export async function revalidateChatsPage() {
revalidateTag("chats-list");
}

View File

@ -40,7 +40,7 @@ export const createChat = async (
export const getChatsList = async (): Promise<IGetChatsListResponse> => { export const getChatsList = async (): Promise<IGetChatsListResponse> => {
return http.get<IGetChatsListResponse>(API_ROUTES.getChatsList(), { return http.get<IGetChatsListResponse>(API_ROUTES.getChatsList(), {
tags: ["chats", "list"], tags: ["chats-list"],
schema: GetChatsListResponseSchema, schema: GetChatsListResponseSchema,
revalidate: 0, revalidate: 0,
}); });

View File

@ -1,18 +1,12 @@
import { cache } from "react";
import { createAllChats, getChatMessages, getChatsList } from "./api"; import { createAllChats, getChatMessages, getChatsList } from "./api";
export const loadCreateAllChats = cache(createAllChats); export const loadCreateAllChats = createAllChats;
export const loadChatsList = cache(getChatsList); export const loadChatsList = getChatsList;
export const loadCategorizedChats = cache(() => export const loadCategorizedChats = () =>
loadChatsList().then(d => d.categorizedChats) loadChatsList().then(d => d.categorizedChats);
); export const loadUnreadChats = () => loadChatsList().then(d => d.unreadChats);
export const loadUnreadChats = cache(() => export const loadCorrespondenceStarted = () =>
loadChatsList().then(d => d.unreadChats) loadChatsList().then(d => d.startedChats);
);
export const loadCorrespondenceStarted = cache(() =>
loadChatsList().then(d => d.startedChats)
);
export const loadChatMessages = cache(getChatMessages); export const loadChatMessages = getChatMessages;

View File

@ -63,9 +63,7 @@ export const useChatSocket = (
const [refillModals, setRefillModals] = useState<IRefillModals | null>(null); const [refillModals, setRefillModals] = useState<IRefillModals | null>(null);
const isLoadingAdvisorMessage = useMemo(() => { const isLoadingAdvisorMessage = useMemo(() => {
return ( return messages.length > 0 && messages[0].role !== "assistant";
messages.length > 0 && messages[messages.length - 1].role !== "assistant"
);
}, [messages]); }, [messages]);
const joinChat = useCallback( const joinChat = useCallback(
@ -86,7 +84,7 @@ export const useChatSocket = (
createdDate: new Date().toISOString(), createdDate: new Date().toISOString(),
isRead: false, isRead: false,
}; };
setMessages(prev => [...prev, sendingMessage]); setMessages(prev => [sendingMessage, ...prev]);
if (options.onNewMessage) { if (options.onNewMessage) {
options.onNewMessage(sendingMessage); options.onNewMessage(sendingMessage);
} }
@ -145,8 +143,8 @@ export const useChatSocket = (
setMessages(prev => { setMessages(prev => {
const ids = new Set(prev.map(m => m.id)); const ids = new Set(prev.map(m => m.id));
return [ return [
...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
...prev, ...prev,
...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
]; ];
}); });
setPage(nextPage); setPage(nextPage);
@ -182,7 +180,7 @@ export const useChatSocket = (
); );
return Array.from(map.values()).sort( return Array.from(map.values()).sort(
(a, b) => (a, b) =>
new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime() new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()
); );
}); });
@ -196,7 +194,7 @@ export const useChatSocket = (
}); });
useSocketEvent("session_started", s => setSession(s.data)); useSocketEvent("session_started", s => setSession(s.data));
useSocketEvent("session_ended", () => setSession(null)); useSocketEvent("session_ended", () => setSession(null));
useSocketEvent("show_refill_modals", r => setRefillModals(r)); useSocketEvent("show_refill_modals", r => setRefillModals(r.data));
useEffect(() => { useEffect(() => {
if (!session?.maxFinishedAt) return; if (!session?.maxFinishedAt) return;

View File

@ -0,0 +1,41 @@
"use client";
import { useMemo, useState } from "react";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useSocketEvent } from "../socket/useSocketEvent";
interface UseChatsSocketOptions {
initialChats?: IGetChatsListResponse;
}
export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
const initialChats = options.initialChats ?? {
categorizedChats: {},
startedChats: [],
unreadChats: [],
totalUnreadCount: 0,
};
const [chats, setChats] = useState<IGetChatsListResponse>(initialChats);
const [unreadCount, setUnreadCount] = useState<number>(
initialChats.totalUnreadCount
);
useSocketEvent("chats_updated", chats => setChats(chats));
useSocketEvent("unread_messages_count", count =>
setUnreadCount(count.unreadCount)
);
return useMemo(
() => ({
chats,
unreadChats: chats.unreadChats,
startedChats: chats.startedChats,
categorizedChats: chats.categorizedChats,
totalUnreadCount: unreadCount,
}),
[chats, unreadCount]
);
};

View File

@ -0,0 +1,39 @@
"use client";
import { createContext, type ReactNode, useContext, useRef } from "react";
import { useStore } from "zustand";
import { AppUiStore, createAppUiStore } from "@/stores/app-ui-store";
export type AppUiStoreApi = ReturnType<typeof createAppUiStore>;
export const AppUiStoreContext = createContext<AppUiStoreApi | undefined>(
undefined
);
export interface AppUiStoreProviderProps {
children: ReactNode;
}
export const AppUiStoreProvider = ({ children }: AppUiStoreProviderProps) => {
const storeRef = useRef<AppUiStoreApi | null>(null);
if (storeRef.current === null) {
storeRef.current = createAppUiStore();
}
return (
<AppUiStoreContext.Provider value={storeRef.current}>
{children}
</AppUiStoreContext.Provider>
);
};
export const useAppUiStore = <T,>(selector: (store: AppUiStore) => T): T => {
const appUiStoreContext = useContext(AppUiStoreContext);
if (!appUiStoreContext) {
throw new Error(`useAppUiStore must be used within AppUiStoreProvider`);
}
return useStore(appUiStoreContext, selector);
};

View File

@ -13,7 +13,7 @@ import { useChatSocket } from "@/hooks/chats/useChatSocket";
interface ChatContextValue extends ReturnType<typeof useChatSocket> { interface ChatContextValue extends ReturnType<typeof useChatSocket> {
messagesWrapperRef: React.RefObject<HTMLDivElement | null>; messagesWrapperRef: React.RefObject<HTMLDivElement | null>;
scrollToBottom: () => void; scrollToBottom: (behavior?: ScrollBehavior) => void;
} }
const ChatContext = createContext<ChatContextValue | null>(null); const ChatContext = createContext<ChatContextValue | null>(null);
@ -47,11 +47,11 @@ export function ChatProvider({
const messagesWrapperRef = useRef<HTMLDivElement>(null); const messagesWrapperRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
if (messagesWrapperRef.current) { if (messagesWrapperRef.current) {
messagesWrapperRef.current.scrollTo({ messagesWrapperRef.current.scrollTo({
top: messagesWrapperRef.current.scrollHeight, top: messagesWrapperRef.current.scrollHeight,
behavior: "smooth", behavior,
}); });
} }
}, []); }, []);

View File

@ -1,3 +1,4 @@
import { IGetChatsListResponse } from "@/entities/chats/types";
import { Currency } from "@/types"; import { Currency } from "@/types";
export interface IMessage { export interface IMessage {
@ -55,6 +56,10 @@ export interface IRefillModals {
products?: IRefillModalsProduct[]; products?: IRefillModalsProduct[];
} }
export interface IUnreadMessagesCount {
unreadCount: number;
}
export interface ClientToServerEvents { export interface ClientToServerEvents {
join_chat: (data: { chatId: string }) => void; join_chat: (data: { chatId: string }) => void;
leave_chat: (data: { chatId: string }) => void; leave_chat: (data: { chatId: string }) => void;
@ -85,5 +90,9 @@ export interface ServerToClientEvents {
data: ServerToClientEventsBaseData<ISessionStarted> data: ServerToClientEventsBaseData<ISessionStarted>
) => void; ) => void;
session_ended: (data: ServerToClientEventsBaseData<boolean>) => void; session_ended: (data: ServerToClientEventsBaseData<boolean>) => void;
show_refill_modals: (data: IRefillModals) => void; show_refill_modals: (
data: ServerToClientEventsBaseData<IRefillModals>
) => void;
chats_updated: (data: IGetChatsListResponse) => void;
unread_messages_count: (data: IUnreadMessagesCount) => void;
} }

View File

@ -2,12 +2,12 @@ import { IconName } from "@/components/ui";
import { ROUTES } from "./client-routes"; import { ROUTES } from "./client-routes";
interface NavItem { export interface NavItem {
key: string; key: string;
label: string; label: string;
icon: IconName; icon: IconName;
href: string; href: string;
badge?: number; badgeId?: string;
} }
export const navItems: NavItem[] = [ export const navItems: NavItem[] = [
@ -17,13 +17,13 @@ export const navItems: NavItem[] = [
icon: IconName.Home, icon: IconName.Home,
href: ROUTES.home(), href: ROUTES.home(),
}, },
// { {
// key: "chat", key: "chat",
// label: "Chat", label: "Chat",
// icon: IconName.Chat, icon: IconName.Chat,
// href: ROUTES.chat(), href: ROUTES.chat(),
// badge: 12, badgeId: "unreadCount",
// }, },
{ {
key: "advisers", key: "advisers",
label: "Advi...", label: "Advi...",

102
src/stores/app-ui-store.ts Normal file
View File

@ -0,0 +1,102 @@
"use client";
import { createStore } from "zustand";
import { persist } from "zustand/middleware";
interface AppUiState {
chats: {
correspondenceStarted: {
isVisibleAll: boolean;
};
newMessages: {
isVisibleAll: boolean;
};
};
home: {
newMessages: {
isVisibleAll: boolean;
};
};
_hasHydrated: boolean;
}
export type AppUiActions = {
setChats: (chats: AppUiState["chats"]) => void;
setChatsCorrespondenceStarted: (
correspondenceStarted: AppUiState["chats"]["correspondenceStarted"]
) => void;
setChatsNewMessages: (
newMessages: AppUiState["chats"]["newMessages"]
) => void;
setHomeNewMessages: (newMessages: AppUiState["home"]["newMessages"]) => void;
clearAppUiData: () => void;
setHasHydrated: (hasHydrated: boolean) => void;
};
export type AppUiStore = AppUiState & AppUiActions;
const initialState: AppUiState = {
chats: {
correspondenceStarted: {
isVisibleAll: true,
},
newMessages: {
isVisibleAll: false,
},
},
home: {
newMessages: {
isVisibleAll: false,
},
},
_hasHydrated: false,
};
export const createAppUiStore = (initState: AppUiState = initialState) => {
return createStore<AppUiStore>()(
persist(
(set, get) => ({
...initState,
setChats: (chats: AppUiState["chats"]) => set({ chats }),
setChatsCorrespondenceStarted: (
correspondenceStarted: AppUiState["chats"]["correspondenceStarted"]
) =>
set({
chats: {
...get().chats,
correspondenceStarted,
},
}),
setChatsNewMessages: (
newMessages: AppUiState["chats"]["newMessages"]
) =>
set({
chats: {
...get().chats,
newMessages,
},
}),
setHomeNewMessages: (newMessages: AppUiState["home"]["newMessages"]) =>
set({ home: { ...get().home, newMessages } }),
clearAppUiData: () => set(initialState),
setHasHydrated: (hasHydrated: boolean) =>
set({ _hasHydrated: hasHydrated }),
}),
{
name: "app-ui-storage",
onRehydrateStorage: () => state => {
// Вызывается после загрузки данных из localStorage
if (state) {
state.setHasHydrated(true);
}
},
}
)
);
};