AW-496-connect-chats
This commit is contained in:
parent
23e6031b19
commit
cac7c51069
@ -302,14 +302,15 @@
|
|||||||
"pinned_chats": "Pinned Chats"
|
"pinned_chats": "Pinned Chats"
|
||||||
},
|
},
|
||||||
"message_input_placeholder": "Type a message...",
|
"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": {
|
"RefillTimerModal": {
|
||||||
"title": "Refill credits in 1 click",
|
"title": "Refill credits in 1 click",
|
||||||
"subtitle": "<oldCredits></oldCredits> {newCredits} credits for {price}",
|
"subtitle": "<oldCredits></oldCredits> {newCredits} credits for {price}",
|
||||||
"button": "Get Credits",
|
"button": "Get Credits",
|
||||||
"dont_want_to_continue": "I don't want to continue chatting",
|
"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"
|
"seconds": "seconds"
|
||||||
},
|
},
|
||||||
"RefillOptionsModal": {
|
"RefillOptionsModal": {
|
||||||
|
|||||||
138
package-lock.json
generated
138
package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"zod": "^3.25.64",
|
"zod": "^3.25.64",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
@ -1286,6 +1287,12 @@
|
|||||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@ -2624,6 +2631,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.0",
|
"version": "1.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
|
||||||
@ -4402,7 +4448,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@ -5380,6 +5425,68 @@
|
|||||||
"is-arrayish": "^0.3.1"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -5998,6 +6105,35 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sass": "^1.89.2",
|
"sass": "^1.89.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"zod": "^3.25.64",
|
"zod": "^3.25.64",
|
||||||
"zustand": "^5.0.5"
|
"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";
|
import { Suspense } from "react";
|
||||||
// TODO: CLIENT PAGE
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CategoryChats,
|
ChatCategories,
|
||||||
ChatItemsList,
|
ChatCategoriesSkeleton,
|
||||||
ChatListHeader,
|
ChatListHeader,
|
||||||
CorrespondenceStarted,
|
CorrespondenceStartedSkeleton,
|
||||||
NewMessages,
|
CorrespondenceStartedWrapper,
|
||||||
|
NewMessagesWrapper,
|
||||||
|
NewMessagesWrapperSkeleton,
|
||||||
} from "@/components/domains/chat";
|
} from "@/components/domains/chat";
|
||||||
import { NavigationBar } from "@/components/layout";
|
import { NavigationBar } from "@/components/layout";
|
||||||
import { ChipProps } from "@/components/ui";
|
import {
|
||||||
import { ChatItemProps } from "@/components/widgets";
|
loadCategorizedChats,
|
||||||
import Chips from "@/components/widgets/Chips/Chips";
|
loadCorrespondenceStarted,
|
||||||
|
loadUnreadChats,
|
||||||
|
} from "@/entities/chats/loaders";
|
||||||
|
|
||||||
import styles from "./page.module.scss";
|
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() {
|
export default function Chats() {
|
||||||
const t = useTranslations("Chat");
|
|
||||||
const [activeChip, setActiveChip] = useState<string>("All");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ChatListHeader />
|
<ChatListHeader />
|
||||||
<section className={styles.categories}>
|
<section className={styles.categories}>
|
||||||
<ChatItemsList title={t("new_messages")}>
|
<Suspense fallback={<NewMessagesWrapperSkeleton />}>
|
||||||
<NewMessages messages={messages} />
|
<NewMessagesWrapper chatsPromise={loadUnreadChats()} />
|
||||||
</ChatItemsList>
|
</Suspense>
|
||||||
<ChatItemsList title={t("correspondence_started.title")}>
|
<Suspense fallback={<CorrespondenceStartedSkeleton />}>
|
||||||
<CorrespondenceStarted messages={messages} />
|
<CorrespondenceStartedWrapper
|
||||||
</ChatItemsList>
|
chatsPromise={loadCorrespondenceStarted()}
|
||||||
<Chips
|
/>
|
||||||
chips={chips}
|
</Suspense>
|
||||||
activeChips={[activeChip]}
|
<Suspense fallback={<ChatCategoriesSkeleton />}>
|
||||||
onChipClick={chip => setActiveChip(chip.text)}
|
<ChatCategories chatsPromise={loadCategorizedChats()} />
|
||||||
/>
|
</Suspense>
|
||||||
<ChatItemsList title={"Психологи Отношений"}>
|
|
||||||
<CategoryChats messages={messages} />
|
|
||||||
</ChatItemsList>
|
|
||||||
<ChatItemsList title={"Астрологи"}>
|
|
||||||
<CategoryChats messages={messages} />
|
|
||||||
</ChatItemsList>
|
|
||||||
</section>
|
</section>
|
||||||
<NavigationBar />
|
<NavigationBar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { ChatStoreProvider } from "@/providers/chat-store-provider";
|
||||||
|
|
||||||
import styles from "./layout.module.scss";
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
export default function ChatLayout({
|
export default function ChatLayout({
|
||||||
@ -6,8 +8,8 @@ export default function ChatLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ChatStoreProvider>
|
||||||
<main className={styles.main}>{children}</main>
|
<main className={styles.main}>{children}</main>
|
||||||
</>
|
</ChatStoreProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,9 +9,13 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
|
|||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { loadUser, loadUserId } from "@/entities/user/loaders";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
|
||||||
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
|
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
|
||||||
|
import SocketProvider from "@/providers/socket-provider";
|
||||||
import { ToastProvider } from "@/providers/toast-provider";
|
import { ToastProvider } from "@/providers/toast-provider";
|
||||||
|
import { UserProvider } from "@/providers/user-provider";
|
||||||
|
|
||||||
import styles from "./layout.module.scss";
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
@ -45,13 +49,22 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
const user = await loadUser();
|
||||||
|
const userId = await loadUserId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale}>
|
||||||
<body className={clsx(inter.variable, styles.body)}>
|
<body className={clsx(inter.variable, styles.body)}>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<RetainingStoreProvider>
|
<UserProvider user={user}>
|
||||||
<ToastProvider maxVisible={3}>{children}</ToastProvider>
|
<SocketProvider userId={userId}>
|
||||||
</RetainingStoreProvider>
|
<RetainingStoreProvider>
|
||||||
|
<ChatsInitializationProvider>
|
||||||
|
<ToastProvider maxVisible={3}>{children}</ToastProvider>
|
||||||
|
</ChatsInitializationProvider>
|
||||||
|
</RetainingStoreProvider>
|
||||||
|
</SocketProvider>
|
||||||
|
</UserProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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";
|
import styles from "./CategoryChats.module.scss";
|
||||||
|
|
||||||
interface CategoryChatsProps {
|
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 (
|
return (
|
||||||
<div className={styles.chats}>
|
<div className={styles.chats}>
|
||||||
{messages.map((message, index) => (
|
{chats.slice(0, maxVisibleChats).map(chat => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
{...message}
|
key={chat.id}
|
||||||
key={`${message.name}-${index}`}
|
|
||||||
className={styles.chat}
|
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>
|
</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 {
|
& > .avatar {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
// background-color: #f3f4f6;
|
||||||
|
background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .chatInfoContent {
|
& > .chatInfoContent {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: 1fr 14px;
|
||||||
|
justify-items: start;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
||||||
& > .name {
|
& > .name {
|
||||||
|
|||||||
@ -1,20 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import {
|
import { Icon, IconName, OnlineIndicator, Typography } from "@/components/ui";
|
||||||
Badge,
|
import { useChat } from "@/providers/chat-provider";
|
||||||
Icon,
|
import { useChatStore } from "@/providers/chat-store-provider";
|
||||||
IconName,
|
import { formatSecondsToHHMMSS } from "@/shared/utils/date";
|
||||||
OnlineIndicator,
|
import { delay } from "@/shared/utils/delay";
|
||||||
Typography,
|
|
||||||
} from "@/components/ui";
|
|
||||||
|
|
||||||
import styles from "./ChatHeader.module.scss";
|
import styles from "./ChatHeader.module.scss";
|
||||||
|
|
||||||
export default function ChatHeader() {
|
export default function ChatHeader() {
|
||||||
|
const t = useTranslations("Chat");
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@ -24,30 +38,34 @@ export default function ChatHeader() {
|
|||||||
size={{ height: 22, width: 22 }}
|
size={{ height: 22, width: 22 }}
|
||||||
color="#374151"
|
color="#374151"
|
||||||
/>
|
/>
|
||||||
<Badge className={styles.badge}>
|
{/* <Badge className={styles.badge}>
|
||||||
<Typography weight="semiBold" size="xs" color="black">
|
<Typography weight="semiBold" size="xs" color="black">
|
||||||
2
|
2
|
||||||
</Typography>
|
</Typography>
|
||||||
</Badge>
|
</Badge> */}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chatInfo}>
|
<div className={styles.chatInfo}>
|
||||||
<Image
|
{!!currentChat?.assistantAvatar ? (
|
||||||
src="/test-user-avatar.png"
|
<Image
|
||||||
alt="Aaron (Taro) avatar"
|
src={currentChat.assistantAvatar}
|
||||||
width={48}
|
alt="Aaron (Taro) avatar"
|
||||||
height={48}
|
width={48}
|
||||||
className={styles.avatar}
|
height={48}
|
||||||
/>
|
className={styles.avatar}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.avatar} />
|
||||||
|
)}
|
||||||
<div className={styles.chatInfoContent}>
|
<div className={styles.chatInfoContent}>
|
||||||
<Typography weight="semiBold" className={styles.name}>
|
<Typography weight="semiBold" className={styles.name}>
|
||||||
Olivia
|
{currentChat?.assistantName}
|
||||||
<OnlineIndicator
|
<OnlineIndicator
|
||||||
isOnline={true}
|
isOnline={currentChat?.status === "inactive"}
|
||||||
className={styles.onlineIndicator}
|
className={styles.onlineIndicator}
|
||||||
/>
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography size="sm" color="secondary">
|
<Typography size="sm" color="secondary">
|
||||||
taping...
|
{isLoadingAdvisorMessage ? t("typing") : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,7 +76,7 @@ export default function ChatHeader() {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
className={styles.timeText}
|
className={styles.timeText}
|
||||||
>
|
>
|
||||||
01:45
|
{formatSecondsToHHMMSS(timer, { isHours: false })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Icon
|
<Icon
|
||||||
name={IconName.Clock}
|
name={IconName.Clock}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { ChatItemsListHeader } from "..";
|
import { ChatItemsListHeader, ViewAllProps } from "..";
|
||||||
|
|
||||||
import styles from "./ChatItemsList.module.scss";
|
import styles from "./ChatItemsList.module.scss";
|
||||||
|
|
||||||
@ -10,22 +10,18 @@ interface ChatItemsListProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
viewAllProps: ViewAllProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatItemsList({
|
export default function ChatItemsList({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
viewAllProps,
|
||||||
}: ChatItemsListProps) {
|
}: ChatItemsListProps) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.chatItemsList, className)}>
|
<div className={clsx(styles.chatItemsList, className)}>
|
||||||
<ChatItemsListHeader
|
<ChatItemsListHeader title={title} viewAllProps={viewAllProps} />
|
||||||
title={title}
|
|
||||||
viewAllProps={{
|
|
||||||
count: 10,
|
|
||||||
// onClick: () => {},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,29 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { useChat } from "@/providers/chat-provider";
|
||||||
|
|
||||||
import MessageAudio from "./MessageAudio/MessageAudio";
|
import MessageAudio from "./MessageAudio/MessageAudio";
|
||||||
import MessageBubble from "./MessageBubble/MessageBubble";
|
import MessageBubble from "./MessageBubble/MessageBubble";
|
||||||
import MessageImage from "./MessageImage/MessageImage";
|
import MessageImage from "./MessageImage/MessageImage";
|
||||||
import MessageMeta from "./MessageMeta/MessageMeta";
|
import MessageMeta from "./MessageMeta/MessageMeta";
|
||||||
import MessageStatus from "./MessageStatus/MessageStatus";
|
import MessageStatus from "./MessageStatus/MessageStatus";
|
||||||
import MessageText from "./MessageText/MessageText";
|
import MessageText from "./MessageText/MessageText";
|
||||||
|
import MessageTyping from "./MessageTyping/MessageTyping";
|
||||||
|
|
||||||
import styles from "./ChatMessage.module.scss";
|
import styles from "./ChatMessage.module.scss";
|
||||||
|
|
||||||
export interface ChatMessageProps {
|
export interface ChatMessageProps {
|
||||||
message: {
|
message: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text" | "image" | "audio";
|
type: "text" | "image" | "audio" | "typing";
|
||||||
content: string;
|
content?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
time: string;
|
time: string | null;
|
||||||
isOwn: boolean;
|
isOwn: boolean;
|
||||||
isRead?: boolean;
|
isRead?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessage({ message }: ChatMessageProps) {
|
export default function ChatMessage({ message }: ChatMessageProps) {
|
||||||
|
const { read } = useChat();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!!message.id && !message.isRead) {
|
||||||
|
read([message.id]);
|
||||||
|
}
|
||||||
|
}, [message.id, message.isRead, read]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.message, message.isOwn && styles.own)}>
|
<div className={clsx(styles.message, message.isOwn && styles.own)}>
|
||||||
<MessageBubble isOwn={message.isOwn}>
|
<MessageBubble isOwn={message.isOwn}>
|
||||||
@ -31,6 +45,8 @@ export default function ChatMessage({ message }: ChatMessageProps) {
|
|||||||
<MessageText text={message.content} isOwn={message.isOwn} />
|
<MessageText text={message.content} isOwn={message.isOwn} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{message.type === "typing" && <MessageTyping />}
|
||||||
|
|
||||||
{message.type === "image" && (
|
{message.type === "image" && (
|
||||||
<>
|
<>
|
||||||
<MessageImage src={message.imageUrl || ""} />
|
<MessageImage src={message.imageUrl || ""} />
|
||||||
|
|||||||
@ -3,16 +3,18 @@ import { Typography } from "@/components/ui";
|
|||||||
import styles from "./MessageMeta.module.scss";
|
import styles from "./MessageMeta.module.scss";
|
||||||
|
|
||||||
interface MessageMetaProps {
|
interface MessageMetaProps {
|
||||||
time: string;
|
time: string | null;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageMeta({ time, children }: MessageMetaProps) {
|
export default function MessageMeta({ time, children }: MessageMetaProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
<Typography size="xs" color="secondary">
|
{time && (
|
||||||
{time}
|
<Typography size="xs" color="secondary">
|
||||||
</Typography>
|
{time}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,16 +5,25 @@ import { Typography } from "@/components/ui";
|
|||||||
import styles from "./MessageText.module.scss";
|
import styles from "./MessageText.module.scss";
|
||||||
|
|
||||||
interface MessageTextProps {
|
interface MessageTextProps {
|
||||||
text: string;
|
text?: string;
|
||||||
isOwn: boolean;
|
isOwn: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageText({ text, isOwn }: MessageTextProps) {
|
export default function MessageText({
|
||||||
|
text,
|
||||||
|
isOwn,
|
||||||
|
className,
|
||||||
|
}: MessageTextProps) {
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Typography
|
||||||
as="p"
|
as="p"
|
||||||
align="left"
|
align="left"
|
||||||
className={clsx(styles.text, isOwn ? styles.own : styles.other)}
|
className={clsx(
|
||||||
|
styles.text,
|
||||||
|
isOwn ? styles.own : styles.other,
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Typography>
|
</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 {
|
interface ChatMessagesProps {
|
||||||
messages: ChatMessageProps["message"][];
|
messages: ChatMessageProps["message"][];
|
||||||
|
isLoadingAdvisorMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessages({ messages }: ChatMessagesProps) {
|
export default function ChatMessages({
|
||||||
|
messages,
|
||||||
|
isLoadingAdvisorMessage,
|
||||||
|
}: ChatMessagesProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{messages.map(message => (
|
{messages.map(message => (
|
||||||
<ChatMessage key={message.id} message={message} />
|
<ChatMessage key={message.id} message={message} />
|
||||||
))}
|
))}
|
||||||
|
{isLoadingAdvisorMessage && (
|
||||||
|
<ChatMessage
|
||||||
|
message={{
|
||||||
|
id: "typing",
|
||||||
|
type: "typing",
|
||||||
|
content: "…",
|
||||||
|
isOwn: false,
|
||||||
|
isRead: false,
|
||||||
|
time: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagesWrapper {
|
.messagesWrapper {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.loaderTop {
|
||||||
flex-shrink: 0;
|
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;
|
padding: 13px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Card, Icon, IconName, Typography } from "@/components/ui";
|
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";
|
import styles from "./CorrespondenceStarted.module.scss";
|
||||||
|
|
||||||
interface CorrespondenceStartedProps {
|
interface CorrespondenceStartedProps {
|
||||||
messages: ChatItemProps[];
|
chats: IChat[];
|
||||||
|
maxVisibleChats?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorrespondenceStarted({
|
export default function CorrespondenceStarted({
|
||||||
messages,
|
chats,
|
||||||
|
maxVisibleChats = 3,
|
||||||
}: CorrespondenceStartedProps) {
|
}: CorrespondenceStartedProps) {
|
||||||
|
const router = useRouter();
|
||||||
const t = useTranslations("Chat");
|
const t = useTranslations("Chat");
|
||||||
|
const setCurrentChat = useChatStore(state => state.setCurrentChat);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={styles.container}>
|
<Card className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@ -22,11 +34,32 @@ export default function CorrespondenceStarted({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chats}>
|
<div className={styles.chats}>
|
||||||
{messages.map((message, index) => (
|
{chats.slice(0, maxVisibleChats).map(chat => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
{...message}
|
key={chat.id}
|
||||||
key={`${message.name}-${index}`}
|
|
||||||
className={styles.chat}
|
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>
|
</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;
|
position: relative;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@ -14,12 +14,12 @@
|
|||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
||||||
&.selected {
|
&.selected.selected {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.popularBadge {
|
.popularBadge.popularBadge {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background-color: #3b82f6;
|
background-color: #3b82f6;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@ -29,30 +29,30 @@
|
|||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkIcon {
|
.checkIcon.checkIcon {
|
||||||
border: 2px solid #b8babf;
|
border: 2px solid #b8babf;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.selected {
|
&.selected.selected {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
background-color: #3b82f6;
|
background-color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.credits {
|
.credits.credits {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bonus {
|
.bonus.bonus {
|
||||||
background-color: #22c55e;
|
background-color: #22c55e;
|
||||||
padding: 2px 17px;
|
padding: 2px 17px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price {
|
.price.price {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import { useTranslations } from "next-intl";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { Button, Icon, IconName, Typography } from "@/components/ui";
|
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";
|
import styles from "./RefillOption.module.scss";
|
||||||
|
|
||||||
interface RefillOptionProps {
|
interface RefillOptionProps {
|
||||||
credits: number;
|
product: IRefillModalsProduct;
|
||||||
price: string;
|
|
||||||
bonus?: number;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
popular?: boolean;
|
popular?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@ -16,15 +16,14 @@ interface RefillOptionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RefillOption({
|
export default function RefillOption({
|
||||||
credits,
|
product,
|
||||||
price,
|
|
||||||
bonus,
|
|
||||||
selected = false,
|
selected = false,
|
||||||
popular = false,
|
popular = false,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: RefillOptionProps) {
|
}: RefillOptionProps) {
|
||||||
const t = useTranslations("RefillOptionsModal.refill_option");
|
const t = useTranslations("RefillOptionsModal.refill_option");
|
||||||
|
const { credits, price, bonus, currency } = product;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -62,7 +61,7 @@ export default function RefillOption({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography size="sm" weight="medium" className={styles.price}>
|
<Typography size="sm" weight="medium" className={styles.price}>
|
||||||
{price}
|
{getFormattedPrice(price, currency)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,47 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { IRefillModalsProduct } from "@/services/socket/events";
|
||||||
|
|
||||||
import RefillOption from "../RefillOption/RefillOption";
|
import RefillOption from "../RefillOption/RefillOption";
|
||||||
|
|
||||||
import styles from "./RefillOptions.module.scss";
|
import styles from "./RefillOptions.module.scss";
|
||||||
|
|
||||||
interface Option {
|
|
||||||
credits: number;
|
|
||||||
price: string;
|
|
||||||
bonus?: number;
|
|
||||||
popular?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RefillOptionsProps {
|
interface RefillOptionsProps {
|
||||||
options: Option[];
|
options: IRefillModalsProduct[];
|
||||||
defaultIndex?: number;
|
selectedOption?: IRefillModalsProduct;
|
||||||
onChange?: (selected: Option, index: number) => void;
|
onChange?: (selected: IRefillModalsProduct) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RefillOptions({
|
export default function RefillOptions({
|
||||||
options,
|
options,
|
||||||
defaultIndex = 0,
|
selectedOption,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
}: RefillOptionsProps) {
|
}: RefillOptionsProps) {
|
||||||
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
|
const handleSelect = (option: IRefillModalsProduct) => {
|
||||||
|
onChange?.(option);
|
||||||
const handleSelect = (index: number) => {
|
|
||||||
setSelectedIndex(index);
|
|
||||||
onChange?.(options[index], index);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.options, className)}>
|
<div className={clsx(styles.options, className)}>
|
||||||
{options.map((option, idx) => (
|
{options.map((option, idx) => (
|
||||||
<RefillOption
|
<RefillOption
|
||||||
key={option.credits}
|
key={option.id}
|
||||||
{...option}
|
product={option}
|
||||||
selected={selectedIndex === idx}
|
popular={idx === 1}
|
||||||
onClick={() => handleSelect(idx)}
|
selected={selectedOption?.id === option.id}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,19 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Typography } from "@/components/ui";
|
import { Typography } from "@/components/ui";
|
||||||
import { useTimer } from "@/hooks/timer/useTimer";
|
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";
|
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 t = useTranslations("RefillOptionsModal.header");
|
||||||
const { time } = useTimer({
|
const { time, isFinished } = useTimer({
|
||||||
initialSeconds: 60,
|
initialSeconds: 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentChat = useChatStore(state => state.currentChat);
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFinished) {
|
||||||
|
onTimerLeft?.();
|
||||||
|
}
|
||||||
|
}, [isFinished, onTimerLeft]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Image
|
<Image
|
||||||
@ -30,10 +48,10 @@ export default function RefillOptionsHeader() {
|
|||||||
align="left"
|
align="left"
|
||||||
className={styles.title}
|
className={styles.title}
|
||||||
>
|
>
|
||||||
{t("title", { name: "Olivia" })}
|
{t("title", { name: currentChat?.assistantName || "" })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography as="p" size="sm" align="left" className={styles.subtitle}>
|
<Typography as="p" size="sm" align="left" className={styles.subtitle}>
|
||||||
{t("subtitle", { name: "Victor" })}
|
{t("subtitle", { name: user?.profile.name || "" })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.timer}>
|
<div className={styles.timer}>
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button, Typography } from "@/components/ui";
|
import { Button, Spinner, Typography } from "@/components/ui";
|
||||||
import { getFormattedPrice } from "@/shared/utils/price";
|
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
|
||||||
import { Currency } from "@/types";
|
import { useChatStore } from "@/providers/chat-store-provider";
|
||||||
|
import { IRefillModals, IRefillModalsProduct } from "@/services/socket/events";
|
||||||
|
|
||||||
import BenefitsList from "../BenefitsList/BenefitsList";
|
import BenefitsList from "../BenefitsList/BenefitsList";
|
||||||
import RefillOptions from "../RefillOptions/RefillOptions";
|
import RefillOptions from "../RefillOptions/RefillOptions";
|
||||||
@ -10,41 +14,63 @@ import RefillOptionsHeader from "../RefillOptionsHeader/RefillOptionsHeader";
|
|||||||
|
|
||||||
import styles from "./RefillOptionsModal.module.scss";
|
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 = [
|
export default function RefillOptionsModal({
|
||||||
{ credits: 100, price: getFormattedPrice(999, currency) },
|
data,
|
||||||
{
|
onTimerLeft,
|
||||||
credits: 250,
|
onPaymentSuccess,
|
||||||
price: getFormattedPrice(1999, currency),
|
onPaymentError,
|
||||||
bonus: 50,
|
}: RefillOptionsModalProps) {
|
||||||
popular: true,
|
|
||||||
},
|
|
||||||
{ credits: 1500, price: getFormattedPrice(9999, currency), bonus: 500 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function RefillOptionsModal() {
|
|
||||||
const t = useTranslations("RefillOptionsModal");
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<RefillOptionsHeader />
|
<RefillOptionsHeader onTimerLeft={onTimerLeft} />
|
||||||
|
|
||||||
<RefillOptions
|
<RefillOptions
|
||||||
className={styles.options}
|
className={styles.options}
|
||||||
options={OPTIONS}
|
options={data}
|
||||||
// onChange={(option, idx) => console.log(option, idx)}
|
selectedOption={selectedOption}
|
||||||
defaultIndex={1}
|
onChange={option => setSelectedOption(option)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button className={styles.button}>
|
<Button className={styles.button} onClick={handlePayment}>
|
||||||
<Typography
|
{!isLoading && (
|
||||||
color="white"
|
<Typography
|
||||||
weight="semiBold"
|
color="white"
|
||||||
className={styles.buttonText}
|
weight="semiBold"
|
||||||
>
|
className={styles.buttonText}
|
||||||
{t("button")}
|
>
|
||||||
</Typography>
|
{t("button")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{isLoading && <Spinner color="white" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<BenefitsList />
|
<BenefitsList />
|
||||||
|
|||||||
@ -30,22 +30,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dontWantToContinue {
|
|
||||||
margin-top: 22px;
|
|
||||||
border-bottom: 1px solid currentColor;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.autoRefillContainer {
|
.autoRefillContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 24px 1fr;
|
grid-template-columns: 24px 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
|
||||||
& > .autoRefillIcon {
|
& > .autoRefillIconButton {
|
||||||
background: #ffffff33;
|
padding: 0;
|
||||||
border-radius: 50%;
|
min-height: 0;
|
||||||
padding: 4px;
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
& > .autoRefillIcon {
|
||||||
|
background: #ffffff33;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 4px;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .autoRefillDescription {
|
& > .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 { useEffect } from "react";
|
||||||
import { CircularProgressbar } from "react-circular-progressbar";
|
import { CircularProgressbar } from "react-circular-progressbar";
|
||||||
import Link from "next/link";
|
|
||||||
import { useTranslations } from "next-intl";
|
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 { 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 { getFormattedPrice } from "@/shared/utils/price";
|
||||||
import { Currency } from "@/types";
|
|
||||||
|
|
||||||
import styles from "./RefillTimerModal.module.scss";
|
import styles from "./RefillTimerModal.module.scss";
|
||||||
|
|
||||||
interface RefillTimerModalProps {
|
interface RefillTimerModalProps {
|
||||||
|
data: NonNullable<IRefillModals["oneClick"]>;
|
||||||
onTimerLeft?: () => void;
|
onTimerLeft?: () => void;
|
||||||
|
onDontWantToContinue?: () => void;
|
||||||
|
onPaymentSuccess?: () => void;
|
||||||
|
onPaymentError?: (error?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMER_SECONDS = 5;
|
|
||||||
|
|
||||||
export default function RefillTimerModal({
|
export default function RefillTimerModal({
|
||||||
|
data,
|
||||||
onTimerLeft,
|
onTimerLeft,
|
||||||
|
onDontWantToContinue,
|
||||||
|
onPaymentSuccess,
|
||||||
|
onPaymentError,
|
||||||
}: RefillTimerModalProps) {
|
}: RefillTimerModalProps) {
|
||||||
|
const { timer, product, autoTopUp } = data;
|
||||||
|
const TIMER_SECONDS = (timer ?? 30_000) / 1000;
|
||||||
|
const { isAutoTopUp, setIsAutoTopUp } = useChatStore(state => state);
|
||||||
|
|
||||||
const t = useTranslations("RefillTimerModal");
|
const t = useTranslations("RefillTimerModal");
|
||||||
const currency = Currency.USD;
|
|
||||||
|
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||||
|
onSuccess: onPaymentSuccess,
|
||||||
|
onError: onPaymentError,
|
||||||
|
});
|
||||||
|
|
||||||
const { seconds, isFinished } = useTimer({
|
const { seconds, isFinished } = useTimer({
|
||||||
initialSeconds: TIMER_SECONDS,
|
initialSeconds: TIMER_SECONDS,
|
||||||
@ -35,6 +49,19 @@ export default function RefillTimerModal({
|
|||||||
}
|
}
|
||||||
}, [isFinished, onTimerLeft]);
|
}, [isFinished, onTimerLeft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAutoTopUp(autoTopUp.value);
|
||||||
|
}, [autoTopUp, setIsAutoTopUp]);
|
||||||
|
|
||||||
|
const handleGetCredits = () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
handleSingleCheckout({
|
||||||
|
productId: product.id,
|
||||||
|
key: product.key,
|
||||||
|
isAutoTopUp,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Typography
|
<Typography
|
||||||
@ -49,8 +76,8 @@ export default function RefillTimerModal({
|
|||||||
<Typography as="p" color="white" className={styles.subtitle}>
|
<Typography as="p" color="white" className={styles.subtitle}>
|
||||||
{t.rich("subtitle", {
|
{t.rich("subtitle", {
|
||||||
oldCredits: () => <span className={styles.oldCredits}>100</span>,
|
oldCredits: () => <span className={styles.oldCredits}>100</span>,
|
||||||
newCredits: 150,
|
newCredits: product.credits,
|
||||||
price: getFormattedPrice(999, currency),
|
price: getFormattedPrice(product.price, product.currency),
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className={styles.progressContainer}>
|
<div className={styles.progressContainer}>
|
||||||
@ -78,27 +105,42 @@ export default function RefillTimerModal({
|
|||||||
strokeWidth={8}
|
strokeWidth={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button className={styles.button}>
|
<Button className={styles.button} onClick={handleGetCredits}>
|
||||||
<Typography
|
{!isLoading && (
|
||||||
color="white"
|
<Typography
|
||||||
weight="semiBold"
|
color="white"
|
||||||
className={styles.buttonText}
|
weight="semiBold"
|
||||||
>
|
className={styles.buttonText}
|
||||||
{t("button")}
|
>
|
||||||
</Typography>
|
{t("button")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{isLoading && <Spinner color="white" />}
|
||||||
</Button>
|
</Button>
|
||||||
<Link href={ROUTES.home()}>
|
<Button
|
||||||
<Typography as="p" color="white" className={styles.dontWantToContinue}>
|
className={styles.dontWantToContinue}
|
||||||
|
onClick={onDontWantToContinue}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
as="p"
|
||||||
|
color="white"
|
||||||
|
className={styles.dontWantToContinueText}
|
||||||
|
>
|
||||||
{t("dont_want_to_continue")}
|
{t("dont_want_to_continue")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Link>
|
</Button>
|
||||||
<div className={styles.autoRefillContainer}>
|
<div className={styles.autoRefillContainer}>
|
||||||
<Icon
|
<Button
|
||||||
name={IconName.Check}
|
className={styles.autoRefillIconButton}
|
||||||
size={{ height: 20, width: 20 }}
|
onClick={() => setIsAutoTopUp(!isAutoTopUp)}
|
||||||
color="#fff"
|
>
|
||||||
className={styles.autoRefillIcon}
|
<Icon
|
||||||
/>
|
name={IconName.Check}
|
||||||
|
size={{ height: 20, width: 20 }}
|
||||||
|
color={isAutoTopUp ? "#fff" : "#ffffff33"}
|
||||||
|
className={styles.autoRefillIcon}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
<Typography
|
<Typography
|
||||||
as="p"
|
as="p"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -107,9 +149,9 @@ export default function RefillTimerModal({
|
|||||||
className={styles.autoRefillDescription}
|
className={styles.autoRefillDescription}
|
||||||
>
|
>
|
||||||
{t("auto_refill_description", {
|
{t("auto_refill_description", {
|
||||||
credits: 100,
|
afterCredits: autoTopUp.after,
|
||||||
addCredits: 900,
|
addCredits: autoTopUp.credits,
|
||||||
minutes: 15,
|
minutes: autoTopUp.minutes,
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import styles from "./LastMessagePreview.module.scss";
|
|||||||
export interface LastMessagePreviewProps {
|
export interface LastMessagePreviewProps {
|
||||||
message: {
|
message: {
|
||||||
type: "text" | "voice" | "image";
|
type: "text" | "voice" | "image";
|
||||||
content: string;
|
content?: string;
|
||||||
};
|
};
|
||||||
isTyping?: boolean;
|
isTyping?: boolean;
|
||||||
isRead?: 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;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.newMessage {
|
.newMessage {
|
||||||
|
|||||||
@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
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 { ROUTES } from "@/shared/constants/client-routes";
|
||||||
|
import { formatTime } from "@/shared/utils/date";
|
||||||
|
|
||||||
import styles from "./NewMessages.module.scss";
|
import styles from "./NewMessages.module.scss";
|
||||||
|
|
||||||
@ -14,30 +17,57 @@ const getTopPositionItem = (index: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface NewMessagesProps {
|
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 router = useRouter();
|
||||||
|
const setCurrentChat = useChatStore(state => state.setCurrentChat);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.container}
|
className={styles.container}
|
||||||
style={{
|
style={{
|
||||||
paddingBottom: getTopPositionItem(messages.length - 1),
|
paddingBottom: getTopPositionItem(chats.length - 1),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{messages.map((message, index) => (
|
{chats.map((chat, index) => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
{...message}
|
key={chat.id}
|
||||||
key={`${message.name}-${index}`}
|
|
||||||
className={styles.newMessage}
|
className={styles.newMessage}
|
||||||
style={{
|
style={
|
||||||
top: `${getTopPositionItem(index)}px`,
|
!isVisibleAll
|
||||||
zIndex: 1111 - index,
|
? {
|
||||||
position: !!index ? "absolute" : "relative",
|
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={() => {
|
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 CategoryChats } from "./CategoryChats/CategoryChats";
|
||||||
|
export {
|
||||||
|
default as ChatCategories,
|
||||||
|
ChatCategoriesSkeleton,
|
||||||
|
} from "./ChatCategories/ChatCategories";
|
||||||
export { default as ChatHeader } from "./ChatHeader/ChatHeader";
|
export { default as ChatHeader } from "./ChatHeader/ChatHeader";
|
||||||
export { default as ChatItemsList } from "./ChatItemsList/ChatItemsList";
|
export { default as ChatItemsList } from "./ChatItemsList/ChatItemsList";
|
||||||
export { default as ChatItemsListHeader } from "./ChatItemsListHeader/ChatItemsListHeader";
|
export { default as ChatItemsListHeader } from "./ChatItemsListHeader/ChatItemsListHeader";
|
||||||
@ -8,7 +12,16 @@ export {
|
|||||||
type ChatMessageProps,
|
type ChatMessageProps,
|
||||||
} from "./ChatMessage/ChatMessage";
|
} from "./ChatMessage/ChatMessage";
|
||||||
export { default as ChatMessages } from "./ChatMessages/ChatMessages";
|
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 { 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 RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
|
||||||
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
|
export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
|
||||||
export {
|
export {
|
||||||
@ -16,5 +29,10 @@ export {
|
|||||||
type LastMessagePreviewProps,
|
type LastMessagePreviewProps,
|
||||||
} from "./LastMessagePreview/LastMessagePreview";
|
} from "./LastMessagePreview/LastMessagePreview";
|
||||||
export { default as MessageInput } from "./MessageInput/MessageInput";
|
export { default as MessageInput } from "./MessageInput/MessageInput";
|
||||||
|
export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper";
|
||||||
export { default as NewMessages } from "./NewMessages/NewMessages";
|
export { default as NewMessages } from "./NewMessages/NewMessages";
|
||||||
|
export {
|
||||||
|
default as NewMessagesWrapper,
|
||||||
|
NewMessagesWrapperSkeleton,
|
||||||
|
} from "./NewMessagesWrapper/NewMessagesWrapper";
|
||||||
export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll";
|
export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll";
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
grid-template-columns: 48px 1fr;
|
grid-template-columns: 48px 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-height: 94px;
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -12,18 +13,20 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
& > .information {
|
& > .information {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .meta {
|
& > .meta {
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|
||||||
& > .time {
|
& > .time {
|
||||||
|
|||||||
@ -17,8 +17,8 @@ import styles from "./ChatItem.module.scss";
|
|||||||
export interface ChatItemProps {
|
export interface ChatItemProps {
|
||||||
userAvatar: UserAvatarProps;
|
userAvatar: UserAvatarProps;
|
||||||
name: string;
|
name: string;
|
||||||
messagePreiew: LastMessagePreviewProps;
|
messagePreiew: LastMessagePreviewProps | null;
|
||||||
time: string;
|
time: string | null;
|
||||||
badgeContent: React.ReactNode;
|
badgeContent: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
@ -45,17 +45,19 @@ export default function ChatItem({
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.information}>
|
<div className={styles.information}>
|
||||||
<Typography weight="semiBold">{name}</Typography>
|
<Typography weight="semiBold">{name}</Typography>
|
||||||
<LastMessagePreview {...messagePreiew} />
|
{messagePreiew && <LastMessagePreview {...messagePreiew} />}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
<Typography size="sm" className={styles.time}>
|
<Typography size="sm" className={styles.time}>
|
||||||
{time}
|
{time || ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Badge className={styles.badge}>
|
{!!badgeContent && (
|
||||||
<Typography weight="medium" size="xs" color="white">
|
<Badge className={styles.badge}>
|
||||||
{badgeContent}
|
<Typography weight="medium" size="xs" color="white">
|
||||||
</Typography>
|
{badgeContent}
|
||||||
</Badge>
|
</Typography>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm
|
|||||||
export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen";
|
export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen";
|
||||||
export { default as BlurComponent } from "./BlurComponent/BlurComponent";
|
export { default as BlurComponent } from "./BlurComponent/BlurComponent";
|
||||||
export { default as ChatItem, type ChatItemProps } from "./ChatItem/ChatItem";
|
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 DatePicker } from "./DatePicker/DatePicker";
|
||||||
export { default as Horoscope } from "./Horoscope/Horoscope";
|
export { default as Horoscope } from "./Horoscope/Horoscope";
|
||||||
export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation";
|
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({
|
export const PaymentInfoSchema = z.object({
|
||||||
productId: z.string(),
|
productId: z.string(),
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
|
isAutoTopUp: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type PaymentInfo = z.infer<typeof PaymentInfoSchema>;
|
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 loadMe = cache(getMe);
|
||||||
|
|
||||||
export const loadUser = cache(() => loadMe().then(d => d.user));
|
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
|
// session
|
||||||
funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2),
|
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",
|
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