AW-496-connect-chats
This commit is contained in:
parent
23e6031b19
commit
cac7c51069
@ -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
138
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
30
src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx
Normal file
30
src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
}
|
||||
19
src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
Normal file
19
src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%" }} />;
|
||||
};
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 || ""} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
.container {
|
||||
.container.container {
|
||||
padding: 13px 0;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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%" }} />;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
.inputWrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.newMessage {
|
||||
|
||||
@ -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));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -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%" }} />;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
59
src/entities/chats/actions.ts
Normal file
59
src/entities/chats/actions.ts
Normal 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
62
src/entities/chats/api.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
};
|
||||
18
src/entities/chats/loaders.ts
Normal file
18
src/entities/chats/loaders.ts
Normal 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);
|
||||
71
src/entities/chats/types.ts
Normal file
71
src/entities/chats/types.ts
Normal 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,
|
||||
};
|
||||
@ -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>;
|
||||
|
||||
|
||||
24
src/entities/user/actions.ts
Normal file
24
src/entities/user/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
299
src/hooks/chats/useChatSocket.ts
Normal file
299
src/hooks/chats/useChatSocket.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
};
|
||||
47
src/hooks/chats/useChatsInitialization.ts
Normal file
47
src/hooks/chats/useChatsInitialization.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
25
src/hooks/socket/useSocketEvent.ts
Normal file
25
src/hooks/socket/useSocketEvent.ts
Normal 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]);
|
||||
}
|
||||
66
src/providers/chat-provider.tsx
Normal file
66
src/providers/chat-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/providers/chat-store-provider.tsx
Normal file
39
src/providers/chat-store-provider.tsx
Normal 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);
|
||||
};
|
||||
46
src/providers/chats-initialization-provider.tsx
Normal file
46
src/providers/chats-initialization-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/providers/socket-provider.tsx
Normal file
31
src/providers/socket-provider.tsx
Normal 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}</>;
|
||||
}
|
||||
30
src/providers/user-provider.tsx
Normal file
30
src/providers/user-provider.tsx
Normal 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;
|
||||
}
|
||||
20
src/services/chats/index.ts
Normal file
20
src/services/chats/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
89
src/services/socket/events.ts
Normal file
89
src/services/socket/events.ts
Normal 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;
|
||||
}
|
||||
170
src/services/socket/index.ts
Normal file
170
src/services/socket/index.ts
Normal 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);
|
||||
@ -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"]),
|
||||
};
|
||||
|
||||
@ -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
38
src/stores/chat-store.ts
Normal 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" }
|
||||
)
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user