AW-496-connect-chats

This commit is contained in:
gofnnp 2025-07-17 23:05:20 +04:00
parent 23e6031b19
commit cac7c51069
66 changed files with 2252 additions and 426 deletions

View File

@ -302,14 +302,15 @@
"pinned_chats": "Pinned Chats"
},
"message_input_placeholder": "Type a message...",
"message_image_fallback": "Failed to load image"
"message_image_fallback": "Failed to load image",
"payment_error": "Something went wrong. Please try again later."
},
"RefillTimerModal": {
"title": "Refill credits in 1 click",
"subtitle": "<oldCredits></oldCredits> {newCredits} credits for {price}",
"button": "Get Credits",
"dont_want_to_continue": "I don't want to continue chatting",
"auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {credits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.",
"auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {afterCredits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.",
"seconds": "seconds"
},
"RefillOptionsModal": {

138
package-lock.json generated
View File

@ -19,6 +19,7 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
@ -1286,6 +1287,12 @@
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -2624,6 +2631,45 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/es-abstract": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@ -4402,7 +4448,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@ -5380,6 +5425,68 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -5998,6 +6105,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -24,6 +24,7 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
},

View File

@ -0,0 +1,30 @@
import { createChat, getChatMessages } from "@/entities/chats/api";
import type { IChatMessage } from "@/entities/chats/types";
import { ChatProvider } from "@/providers/chat-provider";
export default async function ChatLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ assistantId: string }>;
}>) {
const { assistantId } = await params;
const { chatId } = await createChat(assistantId);
const { messages: initialMessages, totalCount } = await getChatMessages(
chatId,
{ limit: 50, page: 1 }
);
return (
<ChatProvider
chatId={chatId}
initialMessages={initialMessages as IChatMessage[]}
initialTotal={totalCount}
>
{children}
</ChatProvider>
);
}

View File

@ -0,0 +1,5 @@
.container {
display: flex;
flex-direction: column;
height: 100dvh;
}

View File

@ -0,0 +1,19 @@
import {
ChatHeader,
ChatMessagesWrapper,
ChatModalsWrapper,
MessageInputWrapper,
} from "@/components/domains/chat";
import styles from "./page.module.scss";
export default function Chat() {
return (
<div className={styles.container}>
<ChatHeader />
<ChatMessagesWrapper />
<MessageInputWrapper />
<ChatModalsWrapper />
</div>
);
}

View File

@ -1,138 +0,0 @@
"use client";
// TODO: client component
import { useEffect, useRef, useState } from "react";
import {
ChatHeader,
ChatMessageProps,
ChatMessages,
MessageInput,
RefillOptionsModal,
RefillTimerModal,
} from "@/components/domains/chat";
import { Button, ModalSheet } from "@/components/ui";
import { formatTime } from "@/shared/utils/date";
import styles from "./page.module.scss";
const staticMessages: ChatMessageProps["message"][] = [
{
id: "1",
type: "text",
content: "It was absolutely amazing! The views were incredible 🏔️",
isOwn: true,
isRead: true,
time: "12:09 AM",
},
{
id: "2",
type: "text",
content:
"Same here, everything's good. Have you made any plans for vacation yet?",
isOwn: false,
time: "12:09 AM",
},
{
id: "4",
type: "image",
content: "What if we take a vacation?",
imageUrl: "/test-user-avatar.png",
isOwn: false,
time: "12:09 AM",
},
{
id: "5",
type: "image",
content: "What if we take a vacation?",
imageUrl: "/adviser-card.png",
isOwn: false,
time: "12:09 AM",
},
{
id: "6",
type: "image",
content:
"What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? ",
imageUrl: "/adviser-card.png",
isOwn: false,
time: "12:09 AM",
},
{
id: "7",
type: "text",
content: "test",
isOwn: true,
isRead: false,
time: "12:09 AM",
},
];
export default function Chat() {
const messagesWrapperRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] =
useState<ChatMessageProps["message"][]>(staticMessages);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChild, setModalChild] = useState<
"refill-timer" | "refill-options"
>("refill-timer");
useEffect(() => {
if (messagesWrapperRef.current) {
messagesWrapperRef.current.scrollTo({
top: messagesWrapperRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [messages]);
const handleSend = (message: string) => {
setMessages(prev => {
const newMessage = {
id: `${prev.length + 2}`,
type: "text",
content: message,
isOwn: true,
time: formatTime(new Date().toISOString()),
} as ChatMessageProps["message"];
return [...prev, newMessage];
});
};
const handleModalClose = () => {
setIsModalOpen(false);
const timeout = setTimeout(() => {
setModalChild("refill-timer");
}, 300);
return () => clearTimeout(timeout);
};
return (
<div className={styles.container}>
<ChatHeader />
<div className={styles.messagesWrapper} ref={messagesWrapperRef}>
<ChatMessages messages={messages} />
</div>
<div className={styles.inputWrapper}>
<MessageInput onSend={handleSend} />
<Button onClick={() => setIsModalOpen(true)}>Test Modal</Button>
</div>
<ModalSheet
open={isModalOpen}
onClose={handleModalClose}
variant={modalChild === "refill-timer" ? "gray" : "white"}
>
{modalChild === "refill-timer" && (
<RefillTimerModal
onTimerLeft={() => setModalChild("refill-options")}
/>
)}
{modalChild === "refill-options" && <RefillOptionsModal />}
</ModalSheet>
</div>
);
}

View File

@ -1,103 +1,39 @@
"use client";
// TODO: CLIENT PAGE
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Suspense } from "react";
import {
CategoryChats,
ChatItemsList,
ChatCategories,
ChatCategoriesSkeleton,
ChatListHeader,
CorrespondenceStarted,
NewMessages,
CorrespondenceStartedSkeleton,
CorrespondenceStartedWrapper,
NewMessagesWrapper,
NewMessagesWrapperSkeleton,
} from "@/components/domains/chat";
import { NavigationBar } from "@/components/layout";
import { ChipProps } from "@/components/ui";
import { ChatItemProps } from "@/components/widgets";
import Chips from "@/components/widgets/Chips/Chips";
import {
loadCategorizedChats,
loadCorrespondenceStarted,
loadUnreadChats,
} from "@/entities/chats/loaders";
import styles from "./page.module.scss";
const messages: ChatItemProps[] = [
{
userAvatar: {
src: "/test-user-avatar.png",
alt: `${"Aaron (Taro)"} avatar`,
isOnline: true,
},
name: "Aaron (Taro)",
messagePreiew: {
message: {
type: "image",
content: "",
},
isRead: true,
},
badgeContent: "3",
time: "09:00 AM",
},
{
userAvatar: {
src: "/test-user-avatar.png",
alt: `${"Aaron (Taro)"} avatar`,
isOnline: true,
},
name: "Aaron (Taro)",
messagePreiew: {
message: {
type: "image",
content: "",
},
isRead: true,
},
badgeContent: "3",
time: "09:00 AM",
},
];
const chips: Omit<ChipProps, "onClick">[] = [
{
text: "All",
},
{
text: "Психологи Отношений",
},
{
text: "Астрологи",
},
{
text: "Таро",
},
{
text: "Нумерологи",
},
];
export default function Chats() {
const t = useTranslations("Chat");
const [activeChip, setActiveChip] = useState<string>("All");
return (
<div className={styles.container}>
<ChatListHeader />
<section className={styles.categories}>
<ChatItemsList title={t("new_messages")}>
<NewMessages messages={messages} />
</ChatItemsList>
<ChatItemsList title={t("correspondence_started.title")}>
<CorrespondenceStarted messages={messages} />
</ChatItemsList>
<Chips
chips={chips}
activeChips={[activeChip]}
onChipClick={chip => setActiveChip(chip.text)}
/>
<ChatItemsList title={"Психологи Отношений"}>
<CategoryChats messages={messages} />
</ChatItemsList>
<ChatItemsList title={"Астрологи"}>
<CategoryChats messages={messages} />
</ChatItemsList>
<Suspense fallback={<NewMessagesWrapperSkeleton />}>
<NewMessagesWrapper chatsPromise={loadUnreadChats()} />
</Suspense>
<Suspense fallback={<CorrespondenceStartedSkeleton />}>
<CorrespondenceStartedWrapper
chatsPromise={loadCorrespondenceStarted()}
/>
</Suspense>
<Suspense fallback={<ChatCategoriesSkeleton />}>
<ChatCategories chatsPromise={loadCategorizedChats()} />
</Suspense>
</section>
<NavigationBar />
</div>

View File

@ -1,3 +1,5 @@
import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss";
export default function ChatLayout({
@ -6,8 +8,8 @@ export default function ChatLayout({
children: React.ReactNode;
}>) {
return (
<>
<ChatStoreProvider>
<main className={styles.main}>{children}</main>
</>
</ChatStoreProvider>
);
}

View File

@ -9,9 +9,13 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import clsx from "clsx";
import { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing";
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
import SocketProvider from "@/providers/socket-provider";
import { ToastProvider } from "@/providers/toast-provider";
import { UserProvider } from "@/providers/user-provider";
import styles from "./layout.module.scss";
@ -45,13 +49,22 @@ export default async function RootLayout({
const messages = await getMessages();
const user = await loadUser();
const userId = await loadUserId();
return (
<html lang={locale}>
<body className={clsx(inter.variable, styles.body)}>
<NextIntlClientProvider messages={messages}>
<RetainingStoreProvider>
<ToastProvider maxVisible={3}>{children}</ToastProvider>
</RetainingStoreProvider>
<UserProvider user={user}>
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<ChatsInitializationProvider>
<ToastProvider maxVisible={3}>{children}</ToastProvider>
</ChatsInitializationProvider>
</RetainingStoreProvider>
</SocketProvider>
</UserProvider>
</NextIntlClientProvider>
</body>
</html>

View File

@ -1,19 +1,55 @@
import { ChatItem, ChatItemProps } from "@/components/widgets";
"use client";
import { useRouter } from "next/navigation";
import { ChatItem } from "@/components/widgets";
import { IChat } from "@/entities/chats/types";
import { useChatStore } from "@/providers/chat-store-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { formatTime } from "@/shared/utils/date";
import styles from "./CategoryChats.module.scss";
interface CategoryChatsProps {
messages: ChatItemProps[];
chats: IChat[];
maxVisibleChats?: number;
}
export default function CategoryChats({ messages }: CategoryChatsProps) {
export default function CategoryChats({
chats,
maxVisibleChats = 3,
}: CategoryChatsProps) {
const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat);
return (
<div className={styles.chats}>
{messages.map((message, index) => (
{chats.slice(0, maxVisibleChats).map(chat => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
key={chat.id}
className={styles.chat}
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>

View File

@ -0,0 +1,68 @@
"use client";
import { use, useState } from "react";
import { Skeleton } from "@/components/ui";
import { Chips } from "@/components/widgets";
import { ICategorizedChats } from "@/entities/chats/types";
import { CategoryChats, ChatItemsList } from "..";
interface ChatCategoriesProps {
chatsPromise: Promise<ICategorizedChats>;
}
export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
const chats = use(chatsPromise);
const [activeChip, setActiveChip] = useState<string>("All");
const [maxVisibleChats, setMaxVisibleChats] = useState<
Partial<Record<string, number | null>>
>({});
const chips = Object.keys(chats).map(key => ({
text: key,
}));
chips.unshift({
text: "All",
});
const filteredChats = Object.keys(chats).filter(key => {
if (activeChip === "All") return true;
return chats[key].some(chat => chat.category === activeChip);
});
return (
<>
<Chips
chips={chips}
activeChips={[activeChip]}
onChipClick={chip => setActiveChip(chip.text)}
/>
{filteredChats.map(key => (
<ChatItemsList
title={key}
key={key}
viewAllProps={{
count: chats[key].length,
onClick: () => {
setMaxVisibleChats(prev => ({
...prev,
[key]: !!prev[key] ? null : chats[key].length,
}));
},
}}
>
<CategoryChats
chats={chats[key]}
maxVisibleChats={maxVisibleChats[key] ?? 3}
/>
</ChatItemsList>
))}
</>
);
}
export const ChatCategoriesSkeleton = () => {
return <Skeleton style={{ height: 300, width: "100%" }} />;
};

View File

@ -44,11 +44,16 @@
& > .avatar {
border-radius: 50%;
width: 48px;
height: 48px;
// background-color: #f3f4f6;
background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%);
}
& > .chatInfoContent {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: 1fr 14px;
justify-items: start;
gap: 2px;
& > .name {

View File

@ -1,20 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import {
Badge,
Icon,
IconName,
OnlineIndicator,
Typography,
} from "@/components/ui";
import { Icon, IconName, OnlineIndicator, Typography } from "@/components/ui";
import { useChat } from "@/providers/chat-provider";
import { useChatStore } from "@/providers/chat-store-provider";
import { formatSecondsToHHMMSS } from "@/shared/utils/date";
import { delay } from "@/shared/utils/delay";
import styles from "./ChatHeader.module.scss";
export default function ChatHeader() {
const t = useTranslations("Chat");
const router = useRouter();
const currentChat = useChatStore(state => state.currentChat);
const { isLoadingAdvisorMessage, isAvailableChatting } = useChat();
const [timer, setTimer] = useState(0);
useEffect(() => {
(async () => {
await delay(1000);
if (isAvailableChatting) {
setTimer(timer + 1);
}
})();
}, [isAvailableChatting, timer]);
return (
<header className={styles.header}>
@ -24,30 +38,34 @@ export default function ChatHeader() {
size={{ height: 22, width: 22 }}
color="#374151"
/>
<Badge className={styles.badge}>
{/* <Badge className={styles.badge}>
<Typography weight="semiBold" size="xs" color="black">
2
</Typography>
</Badge>
</Badge> */}
</div>
<div className={styles.chatInfo}>
<Image
src="/test-user-avatar.png"
alt="Aaron (Taro) avatar"
width={48}
height={48}
className={styles.avatar}
/>
{!!currentChat?.assistantAvatar ? (
<Image
src={currentChat.assistantAvatar}
alt="Aaron (Taro) avatar"
width={48}
height={48}
className={styles.avatar}
/>
) : (
<div className={styles.avatar} />
)}
<div className={styles.chatInfoContent}>
<Typography weight="semiBold" className={styles.name}>
Olivia
{currentChat?.assistantName}
<OnlineIndicator
isOnline={true}
isOnline={currentChat?.status === "inactive"}
className={styles.onlineIndicator}
/>
</Typography>
<Typography size="sm" color="secondary">
taping...
{isLoadingAdvisorMessage ? t("typing") : ""}
</Typography>
</div>
</div>
@ -58,7 +76,7 @@ export default function ChatHeader() {
color="secondary"
className={styles.timeText}
>
01:45
{formatSecondsToHHMMSS(timer, { isHours: false })}
</Typography>
<Icon
name={IconName.Clock}

View File

@ -2,7 +2,7 @@
import clsx from "clsx";
import { ChatItemsListHeader } from "..";
import { ChatItemsListHeader, ViewAllProps } from "..";
import styles from "./ChatItemsList.module.scss";
@ -10,22 +10,18 @@ interface ChatItemsListProps {
className?: string;
children: React.ReactNode;
title: string;
viewAllProps: ViewAllProps;
}
export default function ChatItemsList({
className,
children,
title,
viewAllProps,
}: ChatItemsListProps) {
return (
<div className={clsx(styles.chatItemsList, className)}>
<ChatItemsListHeader
title={title}
viewAllProps={{
count: 10,
// onClick: () => {},
}}
/>
<ChatItemsListHeader title={title} viewAllProps={viewAllProps} />
{children}
</div>
);

View File

@ -1,29 +1,43 @@
"use client";
import { useEffect } from "react";
import clsx from "clsx";
import { useChat } from "@/providers/chat-provider";
import MessageAudio from "./MessageAudio/MessageAudio";
import MessageBubble from "./MessageBubble/MessageBubble";
import MessageImage from "./MessageImage/MessageImage";
import MessageMeta from "./MessageMeta/MessageMeta";
import MessageStatus from "./MessageStatus/MessageStatus";
import MessageText from "./MessageText/MessageText";
import MessageTyping from "./MessageTyping/MessageTyping";
import styles from "./ChatMessage.module.scss";
export interface ChatMessageProps {
message: {
id: string;
type: "text" | "image" | "audio";
content: string;
type: "text" | "image" | "audio" | "typing";
content?: string;
imageUrl?: string;
audioUrl?: string;
duration?: number;
time: string;
time: string | null;
isOwn: boolean;
isRead?: boolean;
};
}
export default function ChatMessage({ message }: ChatMessageProps) {
const { read } = useChat();
useEffect(() => {
if (!!message.id && !message.isRead) {
read([message.id]);
}
}, [message.id, message.isRead, read]);
return (
<div className={clsx(styles.message, message.isOwn && styles.own)}>
<MessageBubble isOwn={message.isOwn}>
@ -31,6 +45,8 @@ export default function ChatMessage({ message }: ChatMessageProps) {
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.type === "typing" && <MessageTyping />}
{message.type === "image" && (
<>
<MessageImage src={message.imageUrl || ""} />

View File

@ -3,16 +3,18 @@ import { Typography } from "@/components/ui";
import styles from "./MessageMeta.module.scss";
interface MessageMetaProps {
time: string;
time: string | null;
children?: React.ReactNode;
}
export default function MessageMeta({ time, children }: MessageMetaProps) {
return (
<div className={styles.meta}>
<Typography size="xs" color="secondary">
{time}
</Typography>
{time && (
<Typography size="xs" color="secondary">
{time}
</Typography>
)}
{children}
</div>
);

View File

@ -5,16 +5,25 @@ import { Typography } from "@/components/ui";
import styles from "./MessageText.module.scss";
interface MessageTextProps {
text: string;
text?: string;
isOwn: boolean;
className?: string;
}
export default function MessageText({ text, isOwn }: MessageTextProps) {
export default function MessageText({
text,
isOwn,
className,
}: MessageTextProps) {
return (
<Typography
as="p"
align="left"
className={clsx(styles.text, isOwn ? styles.own : styles.other)}
className={clsx(
styles.text,
isOwn ? styles.own : styles.other,
className
)}
>
{text}
</Typography>

View File

@ -0,0 +1,35 @@
.loadingMessage {
display: flex;
align-items: flex-end;
gap: 4px;
padding: 16px;
& > .dot {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #333;
animation: bounceDot 1.2s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.15s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
}
}
@keyframes bounceDot {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-6px);
}
}

View File

@ -0,0 +1,11 @@
import styles from "./MessageTyping.module.scss";
export default function MessageTyping() {
return (
<div className={styles.loadingMessage}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
);
}

View File

@ -4,14 +4,30 @@ import styles from "./ChatMessages.module.scss";
interface ChatMessagesProps {
messages: ChatMessageProps["message"][];
isLoadingAdvisorMessage?: boolean;
}
export default function ChatMessages({ messages }: ChatMessagesProps) {
export default function ChatMessages({
messages,
isLoadingAdvisorMessage,
}: ChatMessagesProps) {
return (
<div className={styles.container}>
{messages.map(message => (
<ChatMessage key={message.id} message={message} />
))}
{isLoadingAdvisorMessage && (
<ChatMessage
message={{
id: "typing",
type: "typing",
content: "…",
isOwn: false,
isRead: false,
time: "",
}}
/>
)}
</div>
);
}

View File

@ -1,15 +1,11 @@
.container {
display: flex;
flex-direction: column;
height: 100dvh;
}
.messagesWrapper {
flex: 1 1 0%;
overflow-y: auto;
scroll-behavior: smooth;
}
.inputWrapper {
flex-shrink: 0;
.loaderTop {
display: flex;
justify-content: center;
padding: 8px 0;
}

View File

@ -0,0 +1,71 @@
"use client";
import { useEffect, useMemo } from "react";
import { Spinner } from "@/components/ui";
import { useChat } from "@/providers/chat-provider";
import { formatTime } from "@/shared/utils/date";
import { ChatMessages } from "..";
import styles from "./ChatMessagesWrapper.module.scss";
export default function ChatMessagesWrapper() {
const {
messages: socketMessages,
isLoadingAdvisorMessage,
hasMoreOlderMessages,
isLoadingOlder,
messagesWrapperRef,
scrollToBottom,
} = useChat();
// const handleScroll = useCallback(() => {
// const el = messagesWrapperRef.current;
// if (!el) return;
// if (el.scrollTop < 120) loadOlder();
// }, [loadOlder]);
const mappedMessages = useMemo(() => {
const msgs = socketMessages.map(m => ({
id: m.id,
type: "text" as const,
content: m.text,
isOwn: m.role === "user",
isRead: m.isRead,
time: formatTime(m.createdDate),
}));
return msgs;
}, [socketMessages]);
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
<div
className={styles.messagesWrapper}
ref={messagesWrapperRef}
// onScroll={handleScroll}
>
{isLoadingOlder && hasMoreOlderMessages && (
<div className={styles.loaderTop}>
<Spinner size={16} />
</div>
)}
<ChatMessages
messages={mappedMessages}
isLoadingAdvisorMessage={isLoadingAdvisorMessage}
/>
</div>
);
}
export const ChatMessagesWrapperLoader = () => {
return (
<div className={styles.loaderContainer}>
<Spinner />
</div>
);
};

View File

@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { ModalSheet } from "@/components/ui";
import { useChat } from "@/providers/chat-provider";
import { useToast } from "@/providers/toast-provider";
import { RefillOptionsModal, RefillTimerModal } from "..";
export default function ChatModalsWrapper() {
const t = useTranslations("Chat");
const { refillModals } = useChat();
const { addToast } = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChild, setModalChild] = useState<
"refill-timer" | "refill-options" | null
>(null);
const handleModalClose = () => {
setIsModalOpen(false);
const timeout = setTimeout(() => {
setModalChild(null);
}, 300);
return () => clearTimeout(timeout);
};
useEffect(() => {
if (!!refillModals?.oneClick) {
setIsModalOpen(true);
return setModalChild("refill-timer");
}
if (!!refillModals?.products) {
setIsModalOpen(true);
return setModalChild("refill-options");
}
}, [refillModals]);
const handlePaymentSuccess = () => {
handleModalClose();
};
const handlePaymentError = () => {
addToast({
variant: "error",
message: t("payment_error"),
duration: 5000,
});
};
return (
<ModalSheet
open={isModalOpen}
onClose={handleModalClose}
variant={modalChild === "refill-timer" ? "gray" : "white"}
>
{modalChild === "refill-timer" && !!refillModals?.oneClick && (
<RefillTimerModal
data={refillModals.oneClick}
onTimerLeft={() => setModalChild("refill-options")}
onDontWantToContinue={() => setModalChild("refill-options")}
onPaymentSuccess={handlePaymentSuccess}
onPaymentError={handlePaymentError}
/>
)}
{modalChild === "refill-options" && !!refillModals?.products && (
<RefillOptionsModal
data={refillModals.products}
onPaymentSuccess={handlePaymentSuccess}
onPaymentError={handlePaymentError}
/>
)}
</ModalSheet>
);
}

View File

@ -1,4 +1,4 @@
.container {
.container.container {
padding: 13px 0;
}

View File

@ -1,18 +1,30 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, Icon, IconName, Typography } from "@/components/ui";
import { ChatItem, ChatItemProps } from "@/components/widgets";
import { ChatItem } from "@/components/widgets";
import { IChat } from "@/entities/chats/types";
import { useChatStore } from "@/providers/chat-store-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { formatTime } from "@/shared/utils/date";
import styles from "./CorrespondenceStarted.module.scss";
interface CorrespondenceStartedProps {
messages: ChatItemProps[];
chats: IChat[];
maxVisibleChats?: number;
}
export default function CorrespondenceStarted({
messages,
chats,
maxVisibleChats = 3,
}: CorrespondenceStartedProps) {
const router = useRouter();
const t = useTranslations("Chat");
const setCurrentChat = useChatStore(state => state.setCurrentChat);
return (
<Card className={styles.container}>
<div className={styles.header}>
@ -22,11 +34,32 @@ export default function CorrespondenceStarted({
</Typography>
</div>
<div className={styles.chats}>
{messages.map((message, index) => (
{chats.slice(0, maxVisibleChats).map(chat => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
key={chat.id}
className={styles.chat}
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>

View File

@ -0,0 +1,47 @@
"use client";
import { use, useState } from "react";
import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
import { IChat } from "@/entities/chats/types";
import { ChatItemsList, CorrespondenceStarted } from "..";
interface CorrespondenceStartedWrapperProps {
chatsPromise: Promise<IChat[]>;
}
export default function CorrespondenceStartedWrapper({
chatsPromise,
}: CorrespondenceStartedWrapperProps) {
const t = useTranslations("Chat");
const chats = use(chatsPromise);
const [maxVisibleChats, setMaxVisibleChats] = useState<number | null>(null);
return (
<>
{!!chats.length && (
<ChatItemsList
title={t("correspondence_started.title")}
viewAllProps={{
count: chats.length,
onClick: () => {
setMaxVisibleChats(prev => (prev ? null : chats.length));
},
}}
>
<CorrespondenceStarted
chats={chats}
maxVisibleChats={maxVisibleChats ?? 3}
/>
</ChatItemsList>
)}
</>
);
}
export const CorrespondenceStartedSkeleton = () => {
return <Skeleton style={{ height: 300, width: "100%" }} />;
};

View File

@ -1,4 +1,4 @@
.option {
.option.option {
position: relative;
background-color: #ffffff;
box-shadow:
@ -14,12 +14,12 @@
min-width: 100px;
width: fit-content;
&.selected {
&.selected.selected {
border-color: #3b82f6;
}
}
.popularBadge {
.popularBadge.popularBadge {
padding: 4px 12px;
background-color: #3b82f6;
border-radius: 16px;
@ -29,30 +29,30 @@
transform: translate(-50%, 0);
}
.checkIcon {
.checkIcon.checkIcon {
border: 2px solid #b8babf;
border-radius: 50%;
position: relative;
&.selected {
&.selected.selected {
border-color: #3b82f6;
background-color: #3b82f6;
}
}
.credits {
.credits.credits {
font-size: 20px;
margin-top: 12px;
}
.bonus {
.bonus.bonus {
background-color: #22c55e;
padding: 2px 17px;
border-radius: 999px;
margin-top: 8px;
}
.price {
.price.price {
color: #111827;
margin-top: 16px;
}

View File

@ -2,13 +2,13 @@ import { useTranslations } from "next-intl";
import clsx from "clsx";
import { Button, Icon, IconName, Typography } from "@/components/ui";
import { IRefillModalsProduct } from "@/services/socket/events";
import { getFormattedPrice } from "@/shared/utils/price";
import styles from "./RefillOption.module.scss";
interface RefillOptionProps {
credits: number;
price: string;
bonus?: number;
product: IRefillModalsProduct;
selected?: boolean;
popular?: boolean;
onClick?: () => void;
@ -16,15 +16,14 @@ interface RefillOptionProps {
}
export default function RefillOption({
credits,
price,
bonus,
product,
selected = false,
popular = false,
onClick,
className,
}: RefillOptionProps) {
const t = useTranslations("RefillOptionsModal.refill_option");
const { credits, price, bonus, currency } = product;
return (
<Button
@ -62,7 +61,7 @@ export default function RefillOption({
</Typography>
)}
<Typography size="sm" weight="medium" className={styles.price}>
{price}
{getFormattedPrice(price, currency)}
</Typography>
</Button>
);

View File

@ -1,47 +1,39 @@
"use client";
import { useState } from "react";
import clsx from "clsx";
import { IRefillModalsProduct } from "@/services/socket/events";
import RefillOption from "../RefillOption/RefillOption";
import styles from "./RefillOptions.module.scss";
interface Option {
credits: number;
price: string;
bonus?: number;
popular?: boolean;
}
interface RefillOptionsProps {
options: Option[];
defaultIndex?: number;
onChange?: (selected: Option, index: number) => void;
options: IRefillModalsProduct[];
selectedOption?: IRefillModalsProduct;
onChange?: (selected: IRefillModalsProduct) => void;
className?: string;
}
export default function RefillOptions({
options,
defaultIndex = 0,
selectedOption,
onChange,
className,
}: RefillOptionsProps) {
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
const handleSelect = (index: number) => {
setSelectedIndex(index);
onChange?.(options[index], index);
const handleSelect = (option: IRefillModalsProduct) => {
onChange?.(option);
};
return (
<div className={clsx(styles.options, className)}>
{options.map((option, idx) => (
<RefillOption
key={option.credits}
{...option}
selected={selectedIndex === idx}
onClick={() => handleSelect(idx)}
key={option.id}
product={option}
popular={idx === 1}
selected={selectedOption?.id === option.id}
onClick={() => handleSelect(option)}
/>
))}
</div>

View File

@ -1,19 +1,37 @@
"use client";
import { useEffect } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Typography } from "@/components/ui";
import { useTimer } from "@/hooks/timer/useTimer";
import { useChatStore } from "@/providers/chat-store-provider";
import { useUser } from "@/providers/user-provider";
import styles from "./RefillOptionsHeader.module.scss";
export default function RefillOptionsHeader() {
interface RefillOptionsHeaderProps {
onTimerLeft?: () => void;
}
export default function RefillOptionsHeader({
onTimerLeft,
}: RefillOptionsHeaderProps) {
const t = useTranslations("RefillOptionsModal.header");
const { time } = useTimer({
const { time, isFinished } = useTimer({
initialSeconds: 60,
});
const currentChat = useChatStore(state => state.currentChat);
const { user } = useUser();
useEffect(() => {
if (isFinished) {
onTimerLeft?.();
}
}, [isFinished, onTimerLeft]);
return (
<div className={styles.container}>
<Image
@ -30,10 +48,10 @@ export default function RefillOptionsHeader() {
align="left"
className={styles.title}
>
{t("title", { name: "Olivia" })}
{t("title", { name: currentChat?.assistantName || "" })}
</Typography>
<Typography as="p" size="sm" align="left" className={styles.subtitle}>
{t("subtitle", { name: "Victor" })}
{t("subtitle", { name: user?.profile.name || "" })}
</Typography>
</div>
<div className={styles.timer}>

View File

@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import { Button, Spinner, Typography } from "@/components/ui";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useChatStore } from "@/providers/chat-store-provider";
import { IRefillModals, IRefillModalsProduct } from "@/services/socket/events";
import BenefitsList from "../BenefitsList/BenefitsList";
import RefillOptions from "../RefillOptions/RefillOptions";
@ -10,41 +14,63 @@ import RefillOptionsHeader from "../RefillOptionsHeader/RefillOptionsHeader";
import styles from "./RefillOptionsModal.module.scss";
const currency = Currency.USD;
interface RefillOptionsModalProps {
data: NonNullable<IRefillModals["products"]>;
onTimerLeft?: () => void;
onPaymentSuccess?: () => void;
onPaymentError?: (error?: string) => void;
}
const OPTIONS = [
{ credits: 100, price: getFormattedPrice(999, currency) },
{
credits: 250,
price: getFormattedPrice(1999, currency),
bonus: 50,
popular: true,
},
{ credits: 1500, price: getFormattedPrice(9999, currency), bonus: 500 },
];
export default function RefillOptionsModal() {
export default function RefillOptionsModal({
data,
onTimerLeft,
onPaymentSuccess,
onPaymentError,
}: RefillOptionsModalProps) {
const t = useTranslations("RefillOptionsModal");
const isAutoTopUp = useChatStore(state => state.isAutoTopUp);
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: onPaymentSuccess,
onError: onPaymentError,
});
const [selectedOption, setSelectedOption] = useState<IRefillModalsProduct>(
data[1] ?? data[0]
);
const handlePayment = () => {
if (isLoading) return;
handleSingleCheckout({
productId: selectedOption.id,
key: selectedOption.key,
isAutoTopUp,
});
};
return (
<div className={styles.container}>
<RefillOptionsHeader />
<RefillOptionsHeader onTimerLeft={onTimerLeft} />
<RefillOptions
className={styles.options}
options={OPTIONS}
// onChange={(option, idx) => console.log(option, idx)}
defaultIndex={1}
options={data}
selectedOption={selectedOption}
onChange={option => setSelectedOption(option)}
/>
<Button className={styles.button}>
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
<Button className={styles.button} onClick={handlePayment}>
{!isLoading && (
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
)}
{isLoading && <Spinner color="white" />}
</Button>
<BenefitsList />

View File

@ -30,22 +30,26 @@
}
}
.dontWantToContinue {
margin-top: 22px;
border-bottom: 1px solid currentColor;
padding-bottom: 2px;
}
.autoRefillContainer {
display: grid;
grid-template-columns: 24px 1fr;
gap: 16px;
margin-top: 16px;
& > .autoRefillIcon {
background: #ffffff33;
border-radius: 50%;
padding: 4px;
& > .autoRefillIconButton {
padding: 0;
min-height: 0;
height: fit-content;
width: fit-content;
background: none;
& > .autoRefillIcon {
background: #ffffff33;
border-radius: 50%;
padding: 4px;
height: 20px;
width: 20px;
}
}
& > .autoRefillDescription {
@ -73,3 +77,16 @@
}
}
}
.dontWantToContinue.dontWantToContinue {
padding: 0;
min-height: 0;
width: fit-content;
margin-top: 22px;
background: none;
& > .dontWantToContinueText {
border-bottom: 1px solid currentColor;
padding-bottom: 2px;
}
}

View File

@ -2,28 +2,42 @@
import { useEffect } from "react";
import { CircularProgressbar } from "react-circular-progressbar";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button, Icon, IconName, Typography } from "@/components/ui";
import { Button, Icon, IconName, Spinner, Typography } from "@/components/ui";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useTimer } from "@/hooks/timer/useTimer";
import { ROUTES } from "@/shared/constants/client-routes";
import { useChatStore } from "@/providers/chat-store-provider";
import { IRefillModals } from "@/services/socket/events";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import styles from "./RefillTimerModal.module.scss";
interface RefillTimerModalProps {
data: NonNullable<IRefillModals["oneClick"]>;
onTimerLeft?: () => void;
onDontWantToContinue?: () => void;
onPaymentSuccess?: () => void;
onPaymentError?: (error?: string) => void;
}
const TIMER_SECONDS = 5;
export default function RefillTimerModal({
data,
onTimerLeft,
onDontWantToContinue,
onPaymentSuccess,
onPaymentError,
}: RefillTimerModalProps) {
const { timer, product, autoTopUp } = data;
const TIMER_SECONDS = (timer ?? 30_000) / 1000;
const { isAutoTopUp, setIsAutoTopUp } = useChatStore(state => state);
const t = useTranslations("RefillTimerModal");
const currency = Currency.USD;
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: onPaymentSuccess,
onError: onPaymentError,
});
const { seconds, isFinished } = useTimer({
initialSeconds: TIMER_SECONDS,
@ -35,6 +49,19 @@ export default function RefillTimerModal({
}
}, [isFinished, onTimerLeft]);
useEffect(() => {
setIsAutoTopUp(autoTopUp.value);
}, [autoTopUp, setIsAutoTopUp]);
const handleGetCredits = () => {
if (isLoading) return;
handleSingleCheckout({
productId: product.id,
key: product.key,
isAutoTopUp,
});
};
return (
<div className={styles.container}>
<Typography
@ -49,8 +76,8 @@ export default function RefillTimerModal({
<Typography as="p" color="white" className={styles.subtitle}>
{t.rich("subtitle", {
oldCredits: () => <span className={styles.oldCredits}>100</span>,
newCredits: 150,
price: getFormattedPrice(999, currency),
newCredits: product.credits,
price: getFormattedPrice(product.price, product.currency),
})}
</Typography>
<div className={styles.progressContainer}>
@ -78,27 +105,42 @@ export default function RefillTimerModal({
strokeWidth={8}
/>
</div>
<Button className={styles.button}>
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
<Button className={styles.button} onClick={handleGetCredits}>
{!isLoading && (
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
)}
{isLoading && <Spinner color="white" />}
</Button>
<Link href={ROUTES.home()}>
<Typography as="p" color="white" className={styles.dontWantToContinue}>
<Button
className={styles.dontWantToContinue}
onClick={onDontWantToContinue}
>
<Typography
as="p"
color="white"
className={styles.dontWantToContinueText}
>
{t("dont_want_to_continue")}
</Typography>
</Link>
</Button>
<div className={styles.autoRefillContainer}>
<Icon
name={IconName.Check}
size={{ height: 20, width: 20 }}
color="#fff"
className={styles.autoRefillIcon}
/>
<Button
className={styles.autoRefillIconButton}
onClick={() => setIsAutoTopUp(!isAutoTopUp)}
>
<Icon
name={IconName.Check}
size={{ height: 20, width: 20 }}
color={isAutoTopUp ? "#fff" : "#ffffff33"}
className={styles.autoRefillIcon}
/>
</Button>
<Typography
as="p"
size="sm"
@ -107,9 +149,9 @@ export default function RefillTimerModal({
className={styles.autoRefillDescription}
>
{t("auto_refill_description", {
credits: 100,
addCredits: 900,
minutes: 15,
afterCredits: autoTopUp.after,
addCredits: autoTopUp.credits,
minutes: autoTopUp.minutes,
})}
</Typography>
</div>

View File

@ -7,7 +7,7 @@ import styles from "./LastMessagePreview.module.scss";
export interface LastMessagePreviewProps {
message: {
type: "text" | "voice" | "image";
content: string;
content?: string;
};
isTyping?: boolean;
isRead?: boolean;

View File

@ -0,0 +1,3 @@
.inputWrapper {
flex-shrink: 0;
}

View File

@ -0,0 +1,17 @@
"use client";
import { useChat } from "@/providers/chat-provider";
import { MessageInput } from "..";
import styles from "./MessageInputWrapper.module.scss";
export default function MessageInputWrapper() {
const { send } = useChat();
return (
<div className={styles.inputWrapper}>
<MessageInput onSend={send} />
</div>
);
}

View File

@ -2,6 +2,9 @@
position: relative;
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
gap: 8px;
}
.newMessage {

View File

@ -2,8 +2,11 @@
import { useRouter } from "next/navigation";
import { ChatItem, ChatItemProps } from "@/components/widgets";
import { ChatItem } from "@/components/widgets";
import { IChat } from "@/entities/chats/types";
import { useChatStore } from "@/providers/chat-store-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { formatTime } from "@/shared/utils/date";
import styles from "./NewMessages.module.scss";
@ -14,30 +17,57 @@ const getTopPositionItem = (index: number) => {
};
interface NewMessagesProps {
messages: ChatItemProps[];
chats: IChat[];
isVisibleAll: boolean;
}
export default function NewMessages({ messages }: NewMessagesProps) {
export default function NewMessages({
chats,
isVisibleAll = false,
}: NewMessagesProps) {
const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat);
return (
<div
className={styles.container}
style={{
paddingBottom: getTopPositionItem(messages.length - 1),
paddingBottom: getTopPositionItem(chats.length - 1),
}}
>
{messages.map((message, index) => (
{chats.map((chat, index) => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
key={chat.id}
className={styles.newMessage}
style={{
top: `${getTopPositionItem(index)}px`,
zIndex: 1111 - index,
position: !!index ? "absolute" : "relative",
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={() => {
router.push(ROUTES.chat("test"));
setCurrentChat(chat);
router.push(ROUTES.chat(chat.assistantId));
}}
/>
))}

View File

@ -0,0 +1,44 @@
"use client";
import { use, useState } from "react";
import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
import { IChat } from "@/entities/chats/types";
import { ChatItemsList, NewMessages } from "..";
interface NewMessagesWrapperProps {
chatsPromise: Promise<IChat[]>;
}
export default function NewMessagesWrapper({
chatsPromise,
}: NewMessagesWrapperProps) {
const t = useTranslations("Chat");
const chats = use(chatsPromise);
const [isVisibleAll, setIsVisibleAll] = useState<boolean>(false);
return (
<>
{!!chats.length && (
<ChatItemsList
title={t("new_messages")}
viewAllProps={{
count: chats.length,
onClick: () => {
setIsVisibleAll(prev => !prev);
},
}}
>
<NewMessages chats={chats} isVisibleAll={isVisibleAll} />
</ChatItemsList>
)}
</>
);
}
export const NewMessagesWrapperSkeleton = () => {
return <Skeleton style={{ height: 300, width: "100%" }} />;
};

View File

@ -1,4 +1,8 @@
export { default as CategoryChats } from "./CategoryChats/CategoryChats";
export {
default as ChatCategories,
ChatCategoriesSkeleton,
} from "./ChatCategories/ChatCategories";
export { default as ChatHeader } from "./ChatHeader/ChatHeader";
export { default as ChatItemsList } from "./ChatItemsList/ChatItemsList";
export { default as ChatItemsListHeader } from "./ChatItemsListHeader/ChatItemsListHeader";
@ -8,7 +12,16 @@ export {
type ChatMessageProps,
} from "./ChatMessage/ChatMessage";
export { default as ChatMessages } from "./ChatMessages/ChatMessages";
export {
default as ChatMessagesWrapper,
ChatMessagesWrapperLoader,
} from "./ChatMessagesWrapper/ChatMessagesWrapper";
export { default as ChatModalsWrapper } from "./ChatModalsWrapper/ChatModalsWrapper";
export { default as CorrespondenceStarted } from "./CorrespondenceStarted/CorrespondenceStarted";
export {
CorrespondenceStartedSkeleton,
default as CorrespondenceStartedWrapper,
} from "./CorrespondenceStartedWrapper/CorrespondenceStartedWrapper";
export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
export {
@ -16,5 +29,10 @@ export {
type LastMessagePreviewProps,
} from "./LastMessagePreview/LastMessagePreview";
export { default as MessageInput } from "./MessageInput/MessageInput";
export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper";
export { default as NewMessages } from "./NewMessages/NewMessages";
export {
default as NewMessagesWrapper,
NewMessagesWrapperSkeleton,
} from "./NewMessagesWrapper/NewMessagesWrapper";
export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll";

View File

@ -3,6 +3,7 @@
grid-template-columns: 48px 1fr;
align-items: center;
gap: 12px;
min-height: 94px;
.content {
display: grid;
@ -12,18 +13,20 @@
gap: 12px;
& > .information {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
}
& > .meta {
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
justify-content: flex-start;
gap: 1px;
& > .time {

View File

@ -17,8 +17,8 @@ import styles from "./ChatItem.module.scss";
export interface ChatItemProps {
userAvatar: UserAvatarProps;
name: string;
messagePreiew: LastMessagePreviewProps;
time: string;
messagePreiew: LastMessagePreviewProps | null;
time: string | null;
badgeContent: React.ReactNode;
className?: string;
style?: React.CSSProperties;
@ -45,17 +45,19 @@ export default function ChatItem({
<div className={styles.content}>
<div className={styles.information}>
<Typography weight="semiBold">{name}</Typography>
<LastMessagePreview {...messagePreiew} />
{messagePreiew && <LastMessagePreview {...messagePreiew} />}
</div>
<div className={styles.meta}>
<Typography size="sm" className={styles.time}>
{time}
{time || ""}
</Typography>
<Badge className={styles.badge}>
<Typography weight="medium" size="xs" color="white">
{badgeContent}
</Typography>
</Badge>
{!!badgeContent && (
<Badge className={styles.badge}>
<Typography weight="medium" size="xs" color="white">
{badgeContent}
</Typography>
</Badge>
)}
</div>
</div>
</Card>

View File

@ -2,6 +2,7 @@ export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm
export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen";
export { default as BlurComponent } from "./BlurComponent/BlurComponent";
export { default as ChatItem, type ChatItemProps } from "./ChatItem/ChatItem";
export { default as Chips } from "./Chips/Chips";
export { default as DatePicker } from "./DatePicker/DatePicker";
export { default as Horoscope } from "./Horoscope/Horoscope";
export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation";

View File

@ -0,0 +1,59 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/types";
import {
CreateAllChatsResponseSchema,
GetChatMessagesResponseSchema,
ICreateAllChatsResponse,
IGetChatMessagesResponse,
} from "./types";
export async function createAllChats(): Promise<
ActionResponse<ICreateAllChatsResponse>
> {
try {
const response = await http.post<ICreateAllChatsResponse>(
API_ROUTES.createAllChats(),
{},
{
schema: CreateAllChatsResponseSchema,
revalidate: 0,
}
);
return { data: response, error: null };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to create all chats:", error);
const errorMessage =
error instanceof Error ? error.message : "Something went wrong.";
return { data: null, error: errorMessage };
}
}
export async function fetchChatMessages(
chatId: string,
params: { limit?: number; page?: number } = { limit: 50, page: 1 }
): Promise<ActionResponse<IGetChatMessagesResponse>> {
try {
const response = await http.get<IGetChatMessagesResponse>(
API_ROUTES.getChatMessages(chatId),
{
tags: ["chats", chatId, "messages"],
schema: GetChatMessagesResponseSchema,
query: { limit: params.limit, page: params.page },
revalidate: 0,
}
);
return { data: response, error: null };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to fetch chat messages:", error);
const errorMessage =
error instanceof Error ? error.message : "Something went wrong.";
return { data: null, error: errorMessage };
}
}

62
src/entities/chats/api.ts Normal file
View File

@ -0,0 +1,62 @@
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import {
CreateAllChatsResponseSchema,
CreateChatResponseSchema,
GetChatMessagesResponseSchema,
GetChatsListResponseSchema,
ICreateAllChatsResponse,
ICreateChatResponse,
IGetChatMessagesResponse,
IGetChatsListResponse,
} from "./types";
export const createAllChats = async (): Promise<ICreateAllChatsResponse> => {
return http.post<ICreateAllChatsResponse>(
API_ROUTES.createAllChats(),
{},
{
tags: ["chats", "create-all"],
schema: CreateAllChatsResponseSchema,
revalidate: 0,
}
);
};
export const createChat = async (
assistantId: string
): Promise<ICreateChatResponse> => {
return http.post<ICreateChatResponse>(
API_ROUTES.createChat(assistantId),
{},
{
tags: ["chats", "create"],
schema: CreateChatResponseSchema,
revalidate: 0,
}
);
};
export const getChatsList = async (): Promise<IGetChatsListResponse> => {
return http.get<IGetChatsListResponse>(API_ROUTES.getChatsList(), {
tags: ["chats", "list"],
schema: GetChatsListResponseSchema,
revalidate: 0,
});
};
export const getChatMessages = async (
chatId: string,
params: { limit?: number; page?: number } = { limit: 100, page: 1 }
): Promise<IGetChatMessagesResponse> => {
return http.get<IGetChatMessagesResponse>(
API_ROUTES.getChatMessages(chatId),
{
tags: ["chats", chatId, "messages"],
schema: GetChatMessagesResponseSchema,
query: { limit: params.limit, page: params.page },
revalidate: 0,
}
);
};

View File

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

View File

@ -0,0 +1,71 @@
import { z } from "zod";
const ChatMessageSchema = z.object({
id: z.string(),
role: z.string(),
userId: z.string().optional(),
chatId: z.string().optional(),
createdDate: z.string(),
isRead: z.boolean(),
type: z.enum(["text", "image", "voice"]),
text: z.string().optional(),
suggestions: z.array(z.string()).optional(),
});
const ChatSchema = z.object({
id: z.string(),
assistantId: z.string(),
assistantName: z.string(),
assistantAvatar: z.string(),
lastMessage: ChatMessageSchema.nullable(),
unreadCount: z.number(),
updatedAt: z.string(),
status: z.string(),
category: z.string(),
});
const CategorizedChatsSchema = z.record(z.array(ChatSchema));
const CreateAllChatsResponseSchema = z.object({
success: z.boolean(),
chatIds: z.array(z.string()),
count: z.number(),
});
const CreateChatResponseSchema = z.object({
chatId: z.string(),
});
const GetChatsListResponseSchema = z.object({
categorizedChats: CategorizedChatsSchema,
startedChats: z.array(ChatSchema),
unreadChats: z.array(ChatSchema),
totalUnreadCount: z.number(),
});
const GetChatMessagesResponseSchema = z.object({
messages: z.array(ChatMessageSchema),
totalCount: z.number(),
page: z.number(),
limit: z.number(),
});
export type IChat = z.infer<typeof ChatSchema>;
export type ICategorizedChats = z.infer<typeof CategorizedChatsSchema>;
export type ICreateAllChatsResponse = z.infer<
typeof CreateAllChatsResponseSchema
>;
export type ICreateChatResponse = z.infer<typeof CreateChatResponseSchema>;
export type IGetChatsListResponse = z.infer<typeof GetChatsListResponseSchema>;
export type IChatMessage = z.infer<typeof ChatMessageSchema>;
export type IGetChatMessagesResponse = z.infer<
typeof GetChatMessagesResponseSchema
>;
export {
ChatMessageSchema,
CreateAllChatsResponseSchema,
CreateChatResponseSchema,
GetChatMessagesResponseSchema,
GetChatsListResponseSchema,
};

View File

@ -17,6 +17,7 @@ export type CheckoutResponse = z.infer<typeof CheckoutResponseSchema>;
export const PaymentInfoSchema = z.object({
productId: z.string(),
key: z.string(),
isAutoTopUp: z.boolean().optional(),
});
export type PaymentInfo = z.infer<typeof PaymentInfoSchema>;

View File

@ -0,0 +1,24 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/types";
import { IMeResponse, MeResponseSchema } from "./types";
export async function fetchMe(): Promise<ActionResponse<IMeResponse>> {
try {
const response = await http.get<IMeResponse>(API_ROUTES.usersMe(), {
tags: ["user", "me"],
schema: MeResponseSchema,
revalidate: 0,
});
return { data: response, error: null };
} catch (error) {
// eslint-disable-next-line no-console
console.error("Failed to fetch me:", error);
const errorMessage =
error instanceof Error ? error.message : "Something went wrong.";
return { data: null, error: errorMessage };
}
}

View File

@ -5,3 +5,5 @@ import { getMe } from "./api";
export const loadMe = cache(getMe);
export const loadUser = cache(() => loadMe().then(d => d.user));
export const loadUserId = cache(() => loadUser().then(d => d._id));

View File

@ -0,0 +1,299 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchChatMessages } from "@/entities/chats/actions";
import type { IChatMessage } from "@/entities/chats/types";
import { useSocketEvent } from "@/hooks/socket/useSocketEvent";
import {
ESocketStatus,
useSocketEmit,
useSocketEntity,
useSocketStatus,
} from "@/services/socket";
import type {
ICurrentBalance,
IMessage,
IRefillModals,
ISessionStarted,
} from "@/services/socket/events";
const PAGE_LIMIT = 50;
type UIMessage = Pick<
IMessage,
"id" | "role" | "text" | "createdDate" | "isRead"
>;
interface UseChatSocketOptions {
initialMessages?: IChatMessage[];
initialTotal?: number;
onNewMessage?: (message: UIMessage) => void;
}
export const useChatSocket = (
chatId: string,
options: UseChatSocketOptions = {}
) => {
const socket = useSocketEntity();
const status = useSocketStatus();
const emit = useSocketEmit();
const mapApiMessage = (m: IChatMessage): UIMessage => ({
id: m.id,
role: m.role,
text: m.text,
createdDate: m.createdDate,
isRead: m.isRead,
});
const [messages, setMessages] = useState<UIMessage[]>(() =>
options.initialMessages ? options.initialMessages.map(mapApiMessage) : []
);
const [page, setPage] = useState(1);
const [totalCount, _setTotalCount] = useState<number | null>(
options.initialTotal ?? null
);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [balance, setBalance] = useState<ICurrentBalance | null>(null);
const [session, setSession] = useState<ISessionStarted | null>(null);
const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
// const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false);
const [isSessionExpired, setIsSessionExpired] = useState(false);
const [refillModals, setRefillModals] = useState<IRefillModals | null>(null);
const isLoadingAdvisorMessage = useMemo(() => {
return (
messages.length > 0 && messages[messages.length - 1].role !== "assistant"
);
}, [messages]);
const joinChat = useCallback(
() => emit("join_chat", { chatId }),
[emit, chatId]
);
const leaveChat = useCallback(
() => emit("leave_chat", { chatId }),
[emit, chatId]
);
const send = useCallback(
(text: string) => {
const sendingMessage = {
id: `sending-message-${Date.now()}`,
role: "user",
text,
createdDate: new Date().toISOString(),
isRead: false,
};
setMessages(prev => [...prev, sendingMessage]);
if (options.onNewMessage) {
options.onNewMessage(sendingMessage);
}
setIsLoadingSelfMessage(true);
// setIsLoadingAdvisorMessage(true);
emit("send_message", { chatId, message: text });
},
[options, emit, chatId]
);
const read = useCallback(
(ids: string[]) => emit("read_message", { messages: ids }),
[emit]
);
const startSession = useCallback(
() => emit("start_session", { chatId }),
[emit, chatId]
);
const endSession = useCallback(
() => emit("end_session", { chatId }),
[emit, chatId]
);
const fetchBalance = useCallback(() => {
emit("fetch_balance", { chatId });
}, [emit, chatId]);
const balancePollId = useRef<NodeJS.Timeout | null>(null);
const startBalancePolling = useCallback(() => {
if (balancePollId.current) return;
balancePollId.current = setInterval(fetchBalance, 5_000);
}, [fetchBalance]);
const stopBalancePolling = useCallback(() => {
if (balancePollId.current) {
clearInterval(balancePollId.current);
balancePollId.current = null;
}
}, []);
const hasMoreOlderMessages =
totalCount === null ? false : messages.length < totalCount;
const loadOlder = useCallback(async () => {
if (isLoadingOlder || !hasMoreOlderMessages) return;
setIsLoadingOlder(true);
try {
const nextPage = page + 1;
const { data } = await fetchChatMessages(chatId, {
limit: PAGE_LIMIT,
page: nextPage,
});
if (!data) return;
const { messages: msgs } = data;
setMessages(prev => {
const ids = new Set(prev.map(m => m.id));
return [
...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
...prev,
];
});
setPage(nextPage);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to load older messages:", e);
} finally {
setIsLoadingOlder(false);
}
}, [isLoadingOlder, hasMoreOlderMessages, page, chatId]);
useSocketEvent("receive_message", data => {
if (!data?.length) return;
if (data[0].role === "user") setIsLoadingSelfMessage(false);
// if (data[0].role === "assistant") setIsLoadingAdvisorMessage(false);
setMessages(prev => {
const map = new Map<string, UIMessage>();
prev
.filter(m => !m.id.startsWith("sending-message-"))
.forEach(m => map.set(m.id, m));
data.forEach(d =>
map.set(d.id, {
id: d.id,
role: d.role,
text: d.text,
createdDate: d.createdDate,
isRead: d.isRead,
})
);
return Array.from(map.values()).sort(
(a, b) =>
new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime()
);
});
if (options.onNewMessage) {
options.onNewMessage(data[0]);
}
});
useSocketEvent("current_balance", b => setBalance(b.data));
useSocketEvent("balance_updated", b => {
setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null));
});
useSocketEvent("session_started", s => setSession(s.data));
useSocketEvent("session_ended", () => setSession(null));
useSocketEvent("show_refill_modals", r => setRefillModals(r));
useEffect(() => {
if (!session?.maxFinishedAt) return;
const finishAt = new Date(session.maxFinishedAt).getTime();
const now = Date.now();
const delay = finishAt - now;
if (delay <= 0) {
setIsSessionExpired(true);
return;
}
const id = setTimeout(() => setIsSessionExpired(true), delay);
return () => clearTimeout(id);
}, [session?.maxFinishedAt]);
useEffect(() => {
if (!socket || status !== ESocketStatus.CONNECTED) return;
joinChat();
fetchBalance();
return () => {
leaveChat();
};
}, [socket, status, joinChat, leaveChat, fetchBalance]);
useEffect(() => {
if (session && status === ESocketStatus.CONNECTED) {
startBalancePolling();
} else {
stopBalancePolling();
}
return () => {
stopBalancePolling();
};
}, [session, status, startBalancePolling, stopBalancePolling]);
useEffect(() => {
if (!balance) return;
const hasBalance = balance.balance > 0;
const hasSession = !!session;
if (hasBalance && !hasSession) startSession();
if (!hasBalance && hasSession) endSession();
}, [balance, session, startSession, endSession]);
useEffect(() => {
if (!session) return;
return () => {
endSession();
};
}, [session, endSession]);
const isAvailableChatting =
!!balance?.balance && !!session && !isSessionExpired;
return useMemo(
() => ({
messages,
balance,
session,
refillModals,
send,
read,
startSession,
endSession,
isLoadingSelfMessage,
isLoadingAdvisorMessage,
isAvailableChatting,
isConnected: status === ESocketStatus.CONNECTED,
loadOlder,
isLoadingOlder,
hasMoreOlderMessages,
}),
[
messages,
balance,
session,
refillModals,
isLoadingSelfMessage,
isLoadingAdvisorMessage,
isAvailableChatting,
status,
loadOlder,
isLoadingOlder,
hasMoreOlderMessages,
send,
read,
startSession,
endSession,
]
);
};

View File

@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { createAllChats } from "@/entities/chats/actions";
import { chatsService } from "@/services/chats";
export const useChatsInitialization = () => {
const [isInitializing, setIsInitializing] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initializeChats = async () => {
if (chatsService.isChatsInitialized()) {
setIsInitialized(true);
return;
}
setIsInitializing(true);
setError(null);
try {
const response = await createAllChats();
if (response.data?.success) {
chatsService.setChatsInitialized();
setIsInitialized(true);
} else {
setError("Chats initialization failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setIsInitializing(false);
}
};
initializeChats();
}, []);
return {
isInitializing,
isInitialized,
error,
};
};

View File

@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
import { useStore } from "zustand/react";
import { useSocketStore } from "@/services/socket";
import type { ServerToClientEvents } from "@/services/socket/events";
export function useSocketEvent<E extends keyof ServerToClientEvents>(
event: E,
handler: ServerToClientEvents[E]
) {
const socket = useStore(useSocketStore, state => state.socket);
useEffect(() => {
if (!socket || !handler) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.on(event, handler as any);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
socket.off(event, handler as any);
};
}, [socket, event, handler]);
}

View File

@ -0,0 +1,66 @@
"use client";
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from "react";
import type { IChatMessage } from "@/entities/chats/types";
import { useChatSocket } from "@/hooks/chats/useChatSocket";
interface ChatContextValue extends ReturnType<typeof useChatSocket> {
messagesWrapperRef: React.RefObject<HTMLDivElement | null>;
scrollToBottom: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
export function useChat() {
const ctx = useContext(ChatContext);
if (!ctx) {
throw new Error("useChat must be used within <ChatProvider>");
}
return ctx;
}
interface ChatProviderProps {
chatId: string;
initialMessages?: IChatMessage[];
initialTotal?: number;
children: ReactNode;
}
export function ChatProvider({
chatId,
initialMessages,
initialTotal,
children,
}: ChatProviderProps) {
const value = useChatSocket(chatId, {
initialMessages,
initialTotal,
onNewMessage: _message => scrollToBottom(),
});
const messagesWrapperRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
if (messagesWrapperRef.current) {
messagesWrapperRef.current.scrollTo({
top: messagesWrapperRef.current.scrollHeight,
behavior: "smooth",
});
}
}, []);
return (
<ChatContext.Provider
value={{ ...value, messagesWrapperRef, scrollToBottom }}
>
{children}
</ChatContext.Provider>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { createContext, type ReactNode, useContext, useRef } from "react";
import { useStore } from "zustand";
import { ChatStore, createChatStore } from "@/stores/chat-store";
export type ChatStoreApi = ReturnType<typeof createChatStore>;
export const ChatStoreContext = createContext<ChatStoreApi | undefined>(
undefined
);
export interface ChatStoreProviderProps {
children: ReactNode;
}
export const ChatStoreProvider = ({ children }: ChatStoreProviderProps) => {
const storeRef = useRef<ChatStoreApi | null>(null);
if (storeRef.current === null) {
storeRef.current = createChatStore();
}
return (
<ChatStoreContext.Provider value={storeRef.current}>
{children}
</ChatStoreContext.Provider>
);
};
export const useChatStore = <T,>(selector: (store: ChatStore) => T): T => {
const chatStoreContext = useContext(ChatStoreContext);
if (!chatStoreContext) {
throw new Error(`useChatStore must be used within ChatStoreProvider`);
}
return useStore(chatStoreContext, selector);
};

View File

@ -0,0 +1,46 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import { useChatsInitialization } from "@/hooks/chats/useChatsInitialization";
interface ChatsInitializationContextType {
isInitializing: boolean;
isInitialized: boolean;
error: string | null;
}
const ChatsInitializationContext =
createContext<ChatsInitializationContextType | null>(null);
export const useChatsInitializationContext = () => {
const context = useContext(ChatsInitializationContext);
if (!context) {
throw new Error(
"useChatsInitializationContext must be used within ChatsInitializationProvider"
);
}
return context;
};
interface ChatsInitializationProviderProps {
children: ReactNode;
}
export const ChatsInitializationProvider = ({
children,
}: ChatsInitializationProviderProps) => {
const { isInitializing, isInitialized, error } = useChatsInitialization();
return (
<ChatsInitializationContext.Provider
value={{
isInitializing,
isInitialized,
error,
}}
>
{children}
</ChatsInitializationContext.Provider>
);
};

View File

@ -0,0 +1,31 @@
"use client";
import { ReactNode, useEffect } from "react";
import { useStore } from "zustand";
import { useSocketStore } from "@/services/socket";
interface SocketProviderProps {
userId: string | null;
children: ReactNode;
}
export default function SocketProvider({
userId,
children,
}: SocketProviderProps) {
const connect = useStore(useSocketStore, state => state.connect);
const disconnect = useStore(useSocketStore, state => state.disconnect);
useEffect(() => {
if (!userId) {
disconnect();
return;
}
connect(userId);
return () => disconnect();
}, [connect, disconnect, userId]);
return <>{children}</>;
}

View File

@ -0,0 +1,30 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import { IUser } from "@/entities/user/types";
interface UserContextType {
user: IUser | null;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
interface UserProviderProps {
user: IUser | null;
children: ReactNode;
}
export function UserProvider({ user, children }: UserProviderProps) {
return (
<UserContext.Provider value={{ user }}>{children}</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}

View File

@ -0,0 +1,20 @@
"use client";
const CHATS_INITIALIZED_KEY = "chats-initialized";
export const chatsService = {
isChatsInitialized: (): boolean => {
if (typeof window === "undefined") return false;
return localStorage.getItem(CHATS_INITIALIZED_KEY) === "true";
},
setChatsInitialized: (): void => {
if (typeof window === "undefined") return;
localStorage.setItem(CHATS_INITIALIZED_KEY, "true");
},
clearChatsInitialized: (): void => {
if (typeof window === "undefined") return;
localStorage.removeItem(CHATS_INITIALIZED_KEY);
},
};

View File

@ -0,0 +1,89 @@
import { Currency } from "@/types";
export interface IMessage {
id: string;
role: string;
text?: string;
userId: string;
assistantId: string;
threadId: string;
chatId: string;
createdDate: string;
isRead: boolean;
}
export interface ICurrentBalance {
chatId: string;
cost: number;
maxFinishedAt: string;
balance: number;
}
export interface IBalanceUpdated {
balance: number;
}
export interface ISessionStarted {
chatId: string;
chatSessionId: string;
cost: number;
startedAt: string;
maxFinishedAt: string;
balance: number;
}
export interface IRefillModalsProduct {
id: string;
key: string;
credits: number;
price: number;
currency: Currency;
bonus?: number | null;
}
export interface IRefillModals {
oneClick?: {
timer: number;
product: IRefillModalsProduct;
autoTopUp: {
value: boolean;
after: number;
credits: number;
minutes: number;
};
};
products?: IRefillModalsProduct[];
}
export interface ClientToServerEvents {
join_chat: (data: { chatId: string }) => void;
leave_chat: (data: { chatId: string }) => void;
send_message: (data: { chatId: string; message: string }) => void;
read_message: (data: { messages: string[] }) => void;
start_session: (data: { chatId: string }) => void;
end_session: (data: { chatId: string }) => void;
fetch_balance: (data: { chatId: string }) => void;
deposit: (data: { amount: number }) => void;
}
export interface ServerToClientEventsBaseData<T> {
status: string;
data: T;
}
export interface ServerToClientEvents {
chat_joined: (data: ServerToClientEventsBaseData<null>) => void;
chat_left: (data: ServerToClientEventsBaseData<boolean>) => void;
receive_message: (data: IMessage[]) => void;
current_balance: (
data: ServerToClientEventsBaseData<ICurrentBalance>
) => void;
balance_updated: (
data: ServerToClientEventsBaseData<IBalanceUpdated>
) => void;
session_started: (
data: ServerToClientEventsBaseData<ISessionStarted>
) => void;
session_ended: (data: ServerToClientEventsBaseData<boolean>) => void;
show_refill_modals: (data: IRefillModals) => void;
}

View File

@ -0,0 +1,170 @@
"use client";
import { io, Socket } from "socket.io-client";
import { createStore, useStore } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import type { ClientToServerEvents, ServerToClientEvents } from "./events";
export enum ESocketStatus {
CONNECTING = "connecting",
CONNECTED = "connected",
DISCONNECTED = "disconnected",
ERROR = "error",
}
interface SocketState {
socket: Socket<ServerToClientEvents, ClientToServerEvents> | null;
status: ESocketStatus;
error: string | null;
reconnectAttempt: number;
// queue: { event: keyof ClientToServerEvents; args: unknown[] }[];
reconnectTimeoutId?: ReturnType<typeof setTimeout>;
}
interface SocketActions {
connect: (userId: string) => void;
disconnect: () => void;
emit: <E extends keyof ClientToServerEvents>(
event: E,
...args: Parameters<ClientToServerEvents[E]>
) => void;
clearReconnectTimer: () => void;
// enqueue: <E extends keyof ClientToServerEvents>(
// event: E,
// ...args: Parameters<ClientToServerEvents[E]>
// ) => void;
// flushQueue: () => void;
}
export type SocketStore = SocketState & SocketActions;
const MAX_RECONNECT = 6;
const BASE_DELAY = 2000;
const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL ?? "";
if (!SOCKET_URL) {
// eslint-disable-next-line no-console
console.error("NEXT_PUBLIC_SOCKET_URL env-variable is not set");
}
function expDelay(attempt: number) {
return Math.min(BASE_DELAY * 2 ** attempt, 30_000);
}
export const useSocketStore = createStore<SocketStore>()(
subscribeWithSelector((set, get) => ({
socket: null,
status: ESocketStatus.DISCONNECTED,
error: null,
reconnectAttempt: 0,
reconnectTimeoutId: undefined,
// queue: [],
clearReconnectTimer: () => {
const id = get().reconnectTimeoutId;
if (id) clearTimeout(id);
set({ reconnectTimeoutId: undefined });
},
connect: (userId: string) => {
if (get().socket?.connected || !SOCKET_URL) return;
get().clearReconnectTimer();
set({
status: ESocketStatus.CONNECTING,
error: null,
});
const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
SOCKET_URL,
{
query: { userId },
transports: ["websocket"],
autoConnect: false,
}
);
const cleanListeners = () => {
socket.removeAllListeners();
};
socket.on("connect", () => {
set({ status: ESocketStatus.CONNECTED, reconnectAttempt: 0, socket });
get().clearReconnectTimer();
// get().flushQueue();
});
socket.on("disconnect", _reason => {
set({ status: ESocketStatus.DISCONNECTED });
cleanListeners();
scheduleReconnect();
});
socket.on("connect_error", err => {
// eslint-disable-next-line no-console
console.error("Socket connect_error:", err);
set({ status: ESocketStatus.ERROR, error: err.message });
scheduleReconnect();
});
socket.connect();
set({ socket });
function scheduleReconnect() {
const attempt = get().reconnectAttempt + 1;
if (attempt > MAX_RECONNECT) return;
set({ reconnectAttempt: attempt });
/* CHANGE: сохраняем id таймера в state для дальнейшего clear */
const id = setTimeout(() => get().connect(userId), expDelay(attempt));
set({ reconnectTimeoutId: id });
}
},
disconnect: () => {
get().clearReconnectTimer();
get().socket?.disconnect();
set({
socket: null,
status: ESocketStatus.DISCONNECTED,
error: null,
reconnectAttempt: 0,
// queue: [],
});
},
emit: (event, ...args) => {
const { socket, status } = get();
if (status === ESocketStatus.CONNECTED && socket && socket.connected) {
socket.emit(event, ...args);
} else {
// get().enqueue(event, ...args);
// eslint-disable-next-line no-console
console.warn("NO SOCKET, emit not sent", event, args);
}
},
// enqueue: (event, ...args) =>
// set(state => ({
// queue: [...state.queue, { event, args }],
// })),
// flushQueue: () => {
// const { queue, socket } = get();
// if (!socket) return;
// queue.forEach(({ event, args }) => {
// socket.emit(event, ...args);
// });
// set({ queue: [] });
// },
}))
);
export const useSocketStatus = () =>
useStore(useSocketStore, state => state.status);
export const useSocketEmit = () =>
useStore(useSocketStore, state => state.emit);
export const useSocketEntity = () =>
useStore(useSocketStore, state => state.socket);

View File

@ -32,4 +32,12 @@ export const API_ROUTES = {
// session
funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2),
// chats
createAllChats: () => createRoute(["chats", "create-all"]),
createChat: (assistantId: string) =>
createRoute(["chats", "create", assistantId]),
getChatsList: () => createRoute(["chats", "list"]),
getChatMessages: (chatId: string) =>
createRoute(["chats", chatId, "messages"]),
};

View File

@ -14,3 +14,27 @@ export const formatTime = (date: string | null) => {
minute: "2-digit",
});
};
export const formatSecondsToHHMMSS = (
seconds: number,
availableValues: Partial<
Record<"isHours" | "isMinutes" | "isSeconds", boolean>
>
) => {
const {
isHours = true,
isMinutes = true,
isSeconds = true,
} = availableValues;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return [
isHours && String(hours).padStart(2, "0"),
isMinutes && String(minutes).padStart(2, "0"),
isSeconds && String(secs).padStart(2, "0"),
]
.filter(Boolean)
.join(":");
};

38
src/stores/chat-store.ts Normal file
View File

@ -0,0 +1,38 @@
"use client";
import { createStore } from "zustand";
import { persist } from "zustand/middleware";
import { IChat } from "@/entities/chats/types";
interface ChatState {
currentChat: IChat | null;
isAutoTopUp: boolean;
}
export type ChatActions = {
setCurrentChat: (chat: IChat) => void;
setIsAutoTopUp: (isAutoTopUp: boolean) => void;
clearChatData: () => void;
};
export type ChatStore = ChatState & ChatActions;
const initialState: ChatState = {
currentChat: null,
isAutoTopUp: false,
};
export const createChatStore = (initState: ChatState = initialState) => {
return createStore<ChatStore>()(
persist(
set => ({
...initState,
setCurrentChat: (chat: IChat) => set({ currentChat: chat }),
setIsAutoTopUp: (isAutoTopUp: boolean) => set({ isAutoTopUp }),
clearChatData: () => set(initialState),
}),
{ name: "chat-storage" }
)
);
};