diff --git a/messages/en.json b/messages/en.json index f52e8b4..0bac1d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -302,14 +302,15 @@ "pinned_chats": "Pinned Chats" }, "message_input_placeholder": "Type a message...", - "message_image_fallback": "Failed to load image" + "message_image_fallback": "Failed to load image", + "payment_error": "Something went wrong. Please try again later." }, "RefillTimerModal": { "title": "Refill credits in 1 click", "subtitle": " {newCredits} credits for {price}", "button": "Get Credits", "dont_want_to_continue": "I don't want to continue chatting", - "auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {credits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.", + "auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {afterCredits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.", "seconds": "seconds" }, "RefillOptionsModal": { diff --git a/package-lock.json b/package-lock.json index d75a97c..2b8b666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react-dom": "^19.0.0", "sass": "^1.89.2", "server-only": "^0.0.1", + "socket.io-client": "^4.8.1", "zod": "^3.25.64", "zustand": "^5.0.5" }, @@ -1286,6 +1287,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -2624,6 +2631,45 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -4402,7 +4448,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5380,6 +5425,68 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5998,6 +6105,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8e89db8..4e635de 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-dom": "^19.0.0", "sass": "^1.89.2", "server-only": "^0.0.1", + "socket.io-client": "^4.8.1", "zod": "^3.25.64", "zustand": "^5.0.5" }, diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx b/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx new file mode 100644 index 0000000..b7cdd0e --- /dev/null +++ b/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss new file mode 100644 index 0000000..d7b23e8 --- /dev/null +++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + height: 100dvh; +} diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx new file mode 100644 index 0000000..eef4ad4 --- /dev/null +++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx @@ -0,0 +1,19 @@ +import { + ChatHeader, + ChatMessagesWrapper, + ChatModalsWrapper, + MessageInputWrapper, +} from "@/components/domains/chat"; + +import styles from "./page.module.scss"; + +export default function Chat() { + return ( +
+ + + + +
+ ); +} diff --git a/src/app/[locale]/(chat)/chat/[id]/page.tsx b/src/app/[locale]/(chat)/chat/[id]/page.tsx deleted file mode 100644 index fa58044..0000000 --- a/src/app/[locale]/(chat)/chat/[id]/page.tsx +++ /dev/null @@ -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(null); - - const [messages, setMessages] = - useState(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 ( -
- -
- -
-
- - -
- - {modalChild === "refill-timer" && ( - setModalChild("refill-options")} - /> - )} - {modalChild === "refill-options" && } - -
- ); -} diff --git a/src/app/[locale]/(chat)/chat/page.tsx b/src/app/[locale]/(chat)/chat/page.tsx index 21e3590..b158fdd 100644 --- a/src/app/[locale]/(chat)/chat/page.tsx +++ b/src/app/[locale]/(chat)/chat/page.tsx @@ -1,103 +1,39 @@ -"use client"; -// TODO: CLIENT PAGE - -import { useState } from "react"; -import { useTranslations } from "next-intl"; +import { Suspense } from "react"; import { - CategoryChats, - ChatItemsList, + ChatCategories, + ChatCategoriesSkeleton, ChatListHeader, - CorrespondenceStarted, - NewMessages, + CorrespondenceStartedSkeleton, + CorrespondenceStartedWrapper, + NewMessagesWrapper, + NewMessagesWrapperSkeleton, } from "@/components/domains/chat"; import { NavigationBar } from "@/components/layout"; -import { ChipProps } from "@/components/ui"; -import { ChatItemProps } from "@/components/widgets"; -import Chips from "@/components/widgets/Chips/Chips"; +import { + loadCategorizedChats, + loadCorrespondenceStarted, + loadUnreadChats, +} from "@/entities/chats/loaders"; import styles from "./page.module.scss"; -const messages: ChatItemProps[] = [ - { - userAvatar: { - src: "/test-user-avatar.png", - alt: `${"Aaron (Taro)"} avatar`, - isOnline: true, - }, - name: "Aaron (Taro)", - messagePreiew: { - message: { - type: "image", - content: "", - }, - isRead: true, - }, - badgeContent: "3", - time: "09:00 AM", - }, - { - userAvatar: { - src: "/test-user-avatar.png", - alt: `${"Aaron (Taro)"} avatar`, - isOnline: true, - }, - name: "Aaron (Taro)", - messagePreiew: { - message: { - type: "image", - content: "", - }, - isRead: true, - }, - badgeContent: "3", - time: "09:00 AM", - }, -]; - -const chips: Omit[] = [ - { - text: "All", - }, - { - text: "Психологи Отношений", - }, - { - text: "Астрологи", - }, - { - text: "Таро", - }, - { - text: "Нумерологи", - }, -]; - export default function Chats() { - const t = useTranslations("Chat"); - const [activeChip, setActiveChip] = useState("All"); - return (
- - - - - - - setActiveChip(chip.text)} - /> - - - - - - + }> + + + }> + + + }> + +
diff --git a/src/app/[locale]/(chat)/layout.tsx b/src/app/[locale]/(chat)/layout.tsx index 448d0fb..8b5232e 100644 --- a/src/app/[locale]/(chat)/layout.tsx +++ b/src/app/[locale]/(chat)/layout.tsx @@ -1,3 +1,5 @@ +import { ChatStoreProvider } from "@/providers/chat-store-provider"; + import styles from "./layout.module.scss"; export default function ChatLayout({ @@ -6,8 +8,8 @@ export default function ChatLayout({ children: React.ReactNode; }>) { return ( - <> +
{children}
- +
); } diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 85b1a91..b416ecf 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -9,9 +9,13 @@ import { hasLocale, NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; import clsx from "clsx"; +import { loadUser, loadUserId } from "@/entities/user/loaders"; import { routing } from "@/i18n/routing"; +import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider"; import { RetainingStoreProvider } from "@/providers/retaining-store-provider"; +import SocketProvider from "@/providers/socket-provider"; import { ToastProvider } from "@/providers/toast-provider"; +import { UserProvider } from "@/providers/user-provider"; import styles from "./layout.module.scss"; @@ -45,13 +49,22 @@ export default async function RootLayout({ const messages = await getMessages(); + const user = await loadUser(); + const userId = await loadUserId(); + return ( - - {children} - + + + + + {children} + + + + diff --git a/src/components/domains/chat/CategoryChats/CategoryChats.tsx b/src/components/domains/chat/CategoryChats/CategoryChats.tsx index 36c94f3..565d475 100644 --- a/src/components/domains/chat/CategoryChats/CategoryChats.tsx +++ b/src/components/domains/chat/CategoryChats/CategoryChats.tsx @@ -1,19 +1,55 @@ -import { ChatItem, ChatItemProps } from "@/components/widgets"; +"use client"; + +import { useRouter } from "next/navigation"; + +import { ChatItem } from "@/components/widgets"; +import { IChat } from "@/entities/chats/types"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { ROUTES } from "@/shared/constants/client-routes"; +import { formatTime } from "@/shared/utils/date"; import styles from "./CategoryChats.module.scss"; interface CategoryChatsProps { - messages: ChatItemProps[]; + chats: IChat[]; + maxVisibleChats?: number; } -export default function CategoryChats({ messages }: CategoryChatsProps) { +export default function CategoryChats({ + chats, + maxVisibleChats = 3, +}: CategoryChatsProps) { + const router = useRouter(); + const setCurrentChat = useChatStore(state => state.setCurrentChat); + return (
- {messages.map((message, index) => ( + {chats.slice(0, maxVisibleChats).map(chat => ( { + setCurrentChat(chat); + router.push(ROUTES.chat(chat.assistantId)); + }} /> ))}
diff --git a/src/components/domains/chat/ChatCategories/ChatCategories.tsx b/src/components/domains/chat/ChatCategories/ChatCategories.tsx new file mode 100644 index 0000000..7f851f9 --- /dev/null +++ b/src/components/domains/chat/ChatCategories/ChatCategories.tsx @@ -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; +} + +export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) { + const chats = use(chatsPromise); + const [activeChip, setActiveChip] = useState("All"); + const [maxVisibleChats, setMaxVisibleChats] = useState< + Partial> + >({}); + + 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 ( + <> + setActiveChip(chip.text)} + /> + + {filteredChats.map(key => ( + { + setMaxVisibleChats(prev => ({ + ...prev, + [key]: !!prev[key] ? null : chats[key].length, + })); + }, + }} + > + + + ))} + + ); +} + +export const ChatCategoriesSkeleton = () => { + return ; +}; diff --git a/src/components/domains/chat/ChatHeader/ChatHeader.module.scss b/src/components/domains/chat/ChatHeader/ChatHeader.module.scss index 4bf2205..e32de2b 100644 --- a/src/components/domains/chat/ChatHeader/ChatHeader.module.scss +++ b/src/components/domains/chat/ChatHeader/ChatHeader.module.scss @@ -44,11 +44,16 @@ & > .avatar { border-radius: 50%; + width: 48px; + height: 48px; + // background-color: #f3f4f6; + background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%); } & > .chatInfoContent { - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: 1fr 14px; + justify-items: start; gap: 2px; & > .name { diff --git a/src/components/domains/chat/ChatHeader/ChatHeader.tsx b/src/components/domains/chat/ChatHeader/ChatHeader.tsx index e96a3b0..c32af4c 100644 --- a/src/components/domains/chat/ChatHeader/ChatHeader.tsx +++ b/src/components/domains/chat/ChatHeader/ChatHeader.tsx @@ -1,20 +1,34 @@ "use client"; +import { useEffect, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; -import { - Badge, - Icon, - IconName, - OnlineIndicator, - Typography, -} from "@/components/ui"; +import { Icon, IconName, OnlineIndicator, Typography } from "@/components/ui"; +import { useChat } from "@/providers/chat-provider"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { formatSecondsToHHMMSS } from "@/shared/utils/date"; +import { delay } from "@/shared/utils/delay"; import styles from "./ChatHeader.module.scss"; export default function ChatHeader() { + const t = useTranslations("Chat"); const router = useRouter(); + const currentChat = useChatStore(state => state.currentChat); + const { isLoadingAdvisorMessage, isAvailableChatting } = useChat(); + + const [timer, setTimer] = useState(0); + + useEffect(() => { + (async () => { + await delay(1000); + if (isAvailableChatting) { + setTimer(timer + 1); + } + })(); + }, [isAvailableChatting, timer]); return (
@@ -24,30 +38,34 @@ export default function ChatHeader() { size={{ height: 22, width: 22 }} color="#374151" /> - + {/* 2 - + */}
- Aaron (Taro) avatar + {!!currentChat?.assistantAvatar ? ( + Aaron (Taro) avatar + ) : ( +
+ )}
- Olivia + {currentChat?.assistantName} - taping... + {isLoadingAdvisorMessage ? t("typing") : ""}
@@ -58,7 +76,7 @@ export default function ChatHeader() { color="secondary" className={styles.timeText} > - 01:45 + {formatSecondsToHHMMSS(timer, { isHours: false })} - {}, - }} - /> + {children}
); diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.tsx b/src/components/domains/chat/ChatMessage/ChatMessage.tsx index 55a5baa..d098422 100644 --- a/src/components/domains/chat/ChatMessage/ChatMessage.tsx +++ b/src/components/domains/chat/ChatMessage/ChatMessage.tsx @@ -1,29 +1,43 @@ +"use client"; + +import { useEffect } from "react"; import clsx from "clsx"; +import { useChat } from "@/providers/chat-provider"; + import MessageAudio from "./MessageAudio/MessageAudio"; import MessageBubble from "./MessageBubble/MessageBubble"; import MessageImage from "./MessageImage/MessageImage"; import MessageMeta from "./MessageMeta/MessageMeta"; import MessageStatus from "./MessageStatus/MessageStatus"; import MessageText from "./MessageText/MessageText"; +import MessageTyping from "./MessageTyping/MessageTyping"; import styles from "./ChatMessage.module.scss"; export interface ChatMessageProps { message: { id: string; - type: "text" | "image" | "audio"; - content: string; + type: "text" | "image" | "audio" | "typing"; + content?: string; imageUrl?: string; audioUrl?: string; duration?: number; - time: string; + time: string | null; isOwn: boolean; isRead?: boolean; }; } export default function ChatMessage({ message }: ChatMessageProps) { + const { read } = useChat(); + + useEffect(() => { + if (!!message.id && !message.isRead) { + read([message.id]); + } + }, [message.id, message.isRead, read]); + return (
@@ -31,6 +45,8 @@ export default function ChatMessage({ message }: ChatMessageProps) { )} + {message.type === "typing" && } + {message.type === "image" && ( <> diff --git a/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx index baaf29f..8358043 100644 --- a/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx +++ b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx @@ -3,16 +3,18 @@ import { Typography } from "@/components/ui"; import styles from "./MessageMeta.module.scss"; interface MessageMetaProps { - time: string; + time: string | null; children?: React.ReactNode; } export default function MessageMeta({ time, children }: MessageMetaProps) { return (
- - {time} - + {time && ( + + {time} + + )} {children}
); diff --git a/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx b/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx index fced417..e917056 100644 --- a/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx +++ b/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx @@ -5,16 +5,25 @@ import { Typography } from "@/components/ui"; import styles from "./MessageText.module.scss"; interface MessageTextProps { - text: string; + text?: string; isOwn: boolean; + className?: string; } -export default function MessageText({ text, isOwn }: MessageTextProps) { +export default function MessageText({ + text, + isOwn, + className, +}: MessageTextProps) { return ( {text} diff --git a/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss new file mode 100644 index 0000000..a53982f --- /dev/null +++ b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss @@ -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); + } +} diff --git a/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx new file mode 100644 index 0000000..7780eb7 --- /dev/null +++ b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx @@ -0,0 +1,11 @@ +import styles from "./MessageTyping.module.scss"; + +export default function MessageTyping() { + return ( +
+ + + +
+ ); +} diff --git a/src/components/domains/chat/ChatMessages/ChatMessages.tsx b/src/components/domains/chat/ChatMessages/ChatMessages.tsx index d19a51d..2f2e357 100644 --- a/src/components/domains/chat/ChatMessages/ChatMessages.tsx +++ b/src/components/domains/chat/ChatMessages/ChatMessages.tsx @@ -4,14 +4,30 @@ import styles from "./ChatMessages.module.scss"; interface ChatMessagesProps { messages: ChatMessageProps["message"][]; + isLoadingAdvisorMessage?: boolean; } -export default function ChatMessages({ messages }: ChatMessagesProps) { +export default function ChatMessages({ + messages, + isLoadingAdvisorMessage, +}: ChatMessagesProps) { return (
{messages.map(message => ( ))} + {isLoadingAdvisorMessage && ( + + )}
); } diff --git a/src/app/[locale]/(chat)/chat/[id]/page.module.scss b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss similarity index 52% rename from src/app/[locale]/(chat)/chat/[id]/page.module.scss rename to src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss index 70d5c28..25038c5 100644 --- a/src/app/[locale]/(chat)/chat/[id]/page.module.scss +++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss @@ -1,15 +1,11 @@ -.container { - display: flex; - flex-direction: column; - height: 100dvh; -} - .messagesWrapper { flex: 1 1 0%; overflow-y: auto; scroll-behavior: smooth; } -.inputWrapper { - flex-shrink: 0; +.loaderTop { + display: flex; + justify-content: center; + padding: 8px 0; } diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx new file mode 100644 index 0000000..7d89d1a --- /dev/null +++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx @@ -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 ( +
+ {isLoadingOlder && hasMoreOlderMessages && ( +
+ +
+ )} + +
+ ); +} + +export const ChatMessagesWrapperLoader = () => { + return ( +
+ +
+ ); +}; diff --git a/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.module.scss b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.module.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx new file mode 100644 index 0000000..0d2852e --- /dev/null +++ b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx @@ -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 ( + + {modalChild === "refill-timer" && !!refillModals?.oneClick && ( + setModalChild("refill-options")} + onDontWantToContinue={() => setModalChild("refill-options")} + onPaymentSuccess={handlePaymentSuccess} + onPaymentError={handlePaymentError} + /> + )} + {modalChild === "refill-options" && !!refillModals?.products && ( + + )} + + ); +} diff --git a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss index 29636df..be62616 100644 --- a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss +++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss @@ -1,4 +1,4 @@ -.container { +.container.container { padding: 13px 0; } diff --git a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx index 5e02433..cc0c295 100644 --- a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx +++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx @@ -1,18 +1,30 @@ +"use client"; + +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Card, Icon, IconName, Typography } from "@/components/ui"; -import { ChatItem, ChatItemProps } from "@/components/widgets"; +import { ChatItem } from "@/components/widgets"; +import { IChat } from "@/entities/chats/types"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { ROUTES } from "@/shared/constants/client-routes"; +import { formatTime } from "@/shared/utils/date"; import styles from "./CorrespondenceStarted.module.scss"; interface CorrespondenceStartedProps { - messages: ChatItemProps[]; + chats: IChat[]; + maxVisibleChats?: number; } export default function CorrespondenceStarted({ - messages, + chats, + maxVisibleChats = 3, }: CorrespondenceStartedProps) { + const router = useRouter(); const t = useTranslations("Chat"); + const setCurrentChat = useChatStore(state => state.setCurrentChat); + return (
@@ -22,11 +34,32 @@ export default function CorrespondenceStarted({
- {messages.map((message, index) => ( + {chats.slice(0, maxVisibleChats).map(chat => ( { + setCurrentChat(chat); + router.push(ROUTES.chat(chat.assistantId)); + }} /> ))}
diff --git a/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx new file mode 100644 index 0000000..6c8b78e --- /dev/null +++ b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx @@ -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; +} + +export default function CorrespondenceStartedWrapper({ + chatsPromise, +}: CorrespondenceStartedWrapperProps) { + const t = useTranslations("Chat"); + const chats = use(chatsPromise); + + const [maxVisibleChats, setMaxVisibleChats] = useState(null); + + return ( + <> + {!!chats.length && ( + { + setMaxVisibleChats(prev => (prev ? null : chats.length)); + }, + }} + > + + + )} + + ); +} + +export const CorrespondenceStartedSkeleton = () => { + return ; +}; diff --git a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss index 36b7d1b..0a0eeb0 100644 --- a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss +++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss @@ -1,4 +1,4 @@ -.option { +.option.option { position: relative; background-color: #ffffff; box-shadow: @@ -14,12 +14,12 @@ min-width: 100px; width: fit-content; - &.selected { + &.selected.selected { border-color: #3b82f6; } } -.popularBadge { +.popularBadge.popularBadge { padding: 4px 12px; background-color: #3b82f6; border-radius: 16px; @@ -29,30 +29,30 @@ transform: translate(-50%, 0); } -.checkIcon { +.checkIcon.checkIcon { border: 2px solid #b8babf; border-radius: 50%; position: relative; - &.selected { + &.selected.selected { border-color: #3b82f6; background-color: #3b82f6; } } -.credits { +.credits.credits { font-size: 20px; margin-top: 12px; } -.bonus { +.bonus.bonus { background-color: #22c55e; padding: 2px 17px; border-radius: 999px; margin-top: 8px; } -.price { +.price.price { color: #111827; margin-top: 16px; } diff --git a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx index bd53ded..fdc977e 100644 --- a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx +++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx @@ -2,13 +2,13 @@ import { useTranslations } from "next-intl"; import clsx from "clsx"; import { Button, Icon, IconName, Typography } from "@/components/ui"; +import { IRefillModalsProduct } from "@/services/socket/events"; +import { getFormattedPrice } from "@/shared/utils/price"; import styles from "./RefillOption.module.scss"; interface RefillOptionProps { - credits: number; - price: string; - bonus?: number; + product: IRefillModalsProduct; selected?: boolean; popular?: boolean; onClick?: () => void; @@ -16,15 +16,14 @@ interface RefillOptionProps { } export default function RefillOption({ - credits, - price, - bonus, + product, selected = false, popular = false, onClick, className, }: RefillOptionProps) { const t = useTranslations("RefillOptionsModal.refill_option"); + const { credits, price, bonus, currency } = product; return ( ); diff --git a/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx index d8424c5..5a3bf77 100644 --- a/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx +++ b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx @@ -1,47 +1,39 @@ "use client"; -import { useState } from "react"; import clsx from "clsx"; +import { IRefillModalsProduct } from "@/services/socket/events"; + import RefillOption from "../RefillOption/RefillOption"; import styles from "./RefillOptions.module.scss"; -interface Option { - credits: number; - price: string; - bonus?: number; - popular?: boolean; -} - interface RefillOptionsProps { - options: Option[]; - defaultIndex?: number; - onChange?: (selected: Option, index: number) => void; + options: IRefillModalsProduct[]; + selectedOption?: IRefillModalsProduct; + onChange?: (selected: IRefillModalsProduct) => void; className?: string; } export default function RefillOptions({ options, - defaultIndex = 0, + selectedOption, onChange, className, }: RefillOptionsProps) { - const [selectedIndex, setSelectedIndex] = useState(defaultIndex); - - const handleSelect = (index: number) => { - setSelectedIndex(index); - onChange?.(options[index], index); + const handleSelect = (option: IRefillModalsProduct) => { + onChange?.(option); }; return (
{options.map((option, idx) => ( handleSelect(idx)} + key={option.id} + product={option} + popular={idx === 1} + selected={selectedOption?.id === option.id} + onClick={() => handleSelect(option)} /> ))}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx index d109c75..e1a6499 100644 --- a/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx +++ b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx @@ -1,19 +1,37 @@ "use client"; +import { useEffect } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { Typography } from "@/components/ui"; import { useTimer } from "@/hooks/timer/useTimer"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { useUser } from "@/providers/user-provider"; import styles from "./RefillOptionsHeader.module.scss"; -export default function RefillOptionsHeader() { +interface RefillOptionsHeaderProps { + onTimerLeft?: () => void; +} + +export default function RefillOptionsHeader({ + onTimerLeft, +}: RefillOptionsHeaderProps) { const t = useTranslations("RefillOptionsModal.header"); - const { time } = useTimer({ + const { time, isFinished } = useTimer({ initialSeconds: 60, }); + const currentChat = useChatStore(state => state.currentChat); + const { user } = useUser(); + + useEffect(() => { + if (isFinished) { + onTimerLeft?.(); + } + }, [isFinished, onTimerLeft]); + return (
- {t("title", { name: "Olivia" })} + {t("title", { name: currentChat?.assistantName || "" })} - {t("subtitle", { name: "Victor" })} + {t("subtitle", { name: user?.profile.name || "" })}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx index 055ff05..9063c4a 100644 --- a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx +++ b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx @@ -1,8 +1,12 @@ +"use client"; + +import { useState } from "react"; import { useTranslations } from "next-intl"; -import { Button, Typography } from "@/components/ui"; -import { getFormattedPrice } from "@/shared/utils/price"; -import { Currency } from "@/types"; +import { Button, Spinner, Typography } from "@/components/ui"; +import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { IRefillModals, IRefillModalsProduct } from "@/services/socket/events"; import BenefitsList from "../BenefitsList/BenefitsList"; import RefillOptions from "../RefillOptions/RefillOptions"; @@ -10,41 +14,63 @@ import RefillOptionsHeader from "../RefillOptionsHeader/RefillOptionsHeader"; import styles from "./RefillOptionsModal.module.scss"; -const currency = Currency.USD; +interface RefillOptionsModalProps { + data: NonNullable; + onTimerLeft?: () => void; + onPaymentSuccess?: () => void; + onPaymentError?: (error?: string) => void; +} -const OPTIONS = [ - { credits: 100, price: getFormattedPrice(999, currency) }, - { - credits: 250, - price: getFormattedPrice(1999, currency), - bonus: 50, - popular: true, - }, - { credits: 1500, price: getFormattedPrice(9999, currency), bonus: 500 }, -]; - -export default function RefillOptionsModal() { +export default function RefillOptionsModal({ + data, + onTimerLeft, + onPaymentSuccess, + onPaymentError, +}: RefillOptionsModalProps) { const t = useTranslations("RefillOptionsModal"); + const isAutoTopUp = useChatStore(state => state.isAutoTopUp); + + const { handleSingleCheckout, isLoading } = useSingleCheckout({ + onSuccess: onPaymentSuccess, + onError: onPaymentError, + }); + + const [selectedOption, setSelectedOption] = useState( + data[1] ?? data[0] + ); + + const handlePayment = () => { + if (isLoading) return; + handleSingleCheckout({ + productId: selectedOption.id, + key: selectedOption.key, + isAutoTopUp, + }); + }; + return (
- + console.log(option, idx)} - defaultIndex={1} + options={data} + selectedOption={selectedOption} + onChange={option => setSelectedOption(option)} /> - diff --git a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss index 46a86db..abd0da8 100644 --- a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss +++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss @@ -30,22 +30,26 @@ } } -.dontWantToContinue { - margin-top: 22px; - border-bottom: 1px solid currentColor; - padding-bottom: 2px; -} - .autoRefillContainer { display: grid; grid-template-columns: 24px 1fr; gap: 16px; margin-top: 16px; - & > .autoRefillIcon { - background: #ffffff33; - border-radius: 50%; - padding: 4px; + & > .autoRefillIconButton { + padding: 0; + min-height: 0; + height: fit-content; + width: fit-content; + background: none; + + & > .autoRefillIcon { + background: #ffffff33; + border-radius: 50%; + padding: 4px; + height: 20px; + width: 20px; + } } & > .autoRefillDescription { @@ -73,3 +77,16 @@ } } } + +.dontWantToContinue.dontWantToContinue { + padding: 0; + min-height: 0; + width: fit-content; + margin-top: 22px; + background: none; + + & > .dontWantToContinueText { + border-bottom: 1px solid currentColor; + padding-bottom: 2px; + } +} diff --git a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx index 228ce5e..f19f0da 100644 --- a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx +++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx @@ -2,28 +2,42 @@ import { useEffect } from "react"; import { CircularProgressbar } from "react-circular-progressbar"; -import Link from "next/link"; import { useTranslations } from "next-intl"; -import { Button, Icon, IconName, Typography } from "@/components/ui"; +import { Button, Icon, IconName, Spinner, Typography } from "@/components/ui"; +import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; import { useTimer } from "@/hooks/timer/useTimer"; -import { ROUTES } from "@/shared/constants/client-routes"; +import { useChatStore } from "@/providers/chat-store-provider"; +import { IRefillModals } from "@/services/socket/events"; import { getFormattedPrice } from "@/shared/utils/price"; -import { Currency } from "@/types"; import styles from "./RefillTimerModal.module.scss"; interface RefillTimerModalProps { + data: NonNullable; onTimerLeft?: () => void; + onDontWantToContinue?: () => void; + onPaymentSuccess?: () => void; + onPaymentError?: (error?: string) => void; } -const TIMER_SECONDS = 5; - export default function RefillTimerModal({ + data, onTimerLeft, + onDontWantToContinue, + onPaymentSuccess, + onPaymentError, }: RefillTimerModalProps) { + const { timer, product, autoTopUp } = data; + const TIMER_SECONDS = (timer ?? 30_000) / 1000; + const { isAutoTopUp, setIsAutoTopUp } = useChatStore(state => state); + const t = useTranslations("RefillTimerModal"); - const currency = Currency.USD; + + const { handleSingleCheckout, isLoading } = useSingleCheckout({ + onSuccess: onPaymentSuccess, + onError: onPaymentError, + }); const { seconds, isFinished } = useTimer({ initialSeconds: TIMER_SECONDS, @@ -35,6 +49,19 @@ export default function RefillTimerModal({ } }, [isFinished, onTimerLeft]); + useEffect(() => { + setIsAutoTopUp(autoTopUp.value); + }, [autoTopUp, setIsAutoTopUp]); + + const handleGetCredits = () => { + if (isLoading) return; + handleSingleCheckout({ + productId: product.id, + key: product.key, + isAutoTopUp, + }); + }; + return (
{t.rich("subtitle", { oldCredits: () => 100, - newCredits: 150, - price: getFormattedPrice(999, currency), + newCredits: product.credits, + price: getFormattedPrice(product.price, product.currency), })}
@@ -78,27 +105,42 @@ export default function RefillTimerModal({ strokeWidth={8} />
- - - +
- + {t("auto_refill_description", { - credits: 100, - addCredits: 900, - minutes: 15, + afterCredits: autoTopUp.after, + addCredits: autoTopUp.credits, + minutes: autoTopUp.minutes, })}
diff --git a/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx index fe3f94e..d3fd91c 100644 --- a/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx +++ b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx @@ -7,7 +7,7 @@ import styles from "./LastMessagePreview.module.scss"; export interface LastMessagePreviewProps { message: { type: "text" | "voice" | "image"; - content: string; + content?: string; }; isTyping?: boolean; isRead?: boolean; diff --git a/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss new file mode 100644 index 0000000..8aa4d1a --- /dev/null +++ b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss @@ -0,0 +1,3 @@ +.inputWrapper { + flex-shrink: 0; +} diff --git a/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx new file mode 100644 index 0000000..3036f43 --- /dev/null +++ b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/domains/chat/NewMessages/NewMessages.module.scss b/src/components/domains/chat/NewMessages/NewMessages.module.scss index 3b4e8b3..e88ff89 100644 --- a/src/components/domains/chat/NewMessages/NewMessages.module.scss +++ b/src/components/domains/chat/NewMessages/NewMessages.module.scss @@ -2,6 +2,9 @@ position: relative; width: 100%; height: fit-content; + display: flex; + flex-direction: column; + gap: 8px; } .newMessage { diff --git a/src/components/domains/chat/NewMessages/NewMessages.tsx b/src/components/domains/chat/NewMessages/NewMessages.tsx index 3b23ada..81e5d87 100644 --- a/src/components/domains/chat/NewMessages/NewMessages.tsx +++ b/src/components/domains/chat/NewMessages/NewMessages.tsx @@ -2,8 +2,11 @@ import { useRouter } from "next/navigation"; -import { ChatItem, ChatItemProps } from "@/components/widgets"; +import { ChatItem } from "@/components/widgets"; +import { IChat } from "@/entities/chats/types"; +import { useChatStore } from "@/providers/chat-store-provider"; import { ROUTES } from "@/shared/constants/client-routes"; +import { formatTime } from "@/shared/utils/date"; import styles from "./NewMessages.module.scss"; @@ -14,30 +17,57 @@ const getTopPositionItem = (index: number) => { }; interface NewMessagesProps { - messages: ChatItemProps[]; + chats: IChat[]; + isVisibleAll: boolean; } -export default function NewMessages({ messages }: NewMessagesProps) { +export default function NewMessages({ + chats, + isVisibleAll = false, +}: NewMessagesProps) { const router = useRouter(); + const setCurrentChat = useChatStore(state => state.setCurrentChat); return (
- {messages.map((message, index) => ( + {chats.map((chat, index) => ( { - router.push(ROUTES.chat("test")); + setCurrentChat(chat); + router.push(ROUTES.chat(chat.assistantId)); }} /> ))} diff --git a/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx b/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx new file mode 100644 index 0000000..660d0d2 --- /dev/null +++ b/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx @@ -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; +} + +export default function NewMessagesWrapper({ + chatsPromise, +}: NewMessagesWrapperProps) { + const t = useTranslations("Chat"); + const chats = use(chatsPromise); + + const [isVisibleAll, setIsVisibleAll] = useState(false); + + return ( + <> + {!!chats.length && ( + { + setIsVisibleAll(prev => !prev); + }, + }} + > + + + )} + + ); +} + +export const NewMessagesWrapperSkeleton = () => { + return ; +}; diff --git a/src/components/domains/chat/index.ts b/src/components/domains/chat/index.ts index 4c100fc..e27d64c 100644 --- a/src/components/domains/chat/index.ts +++ b/src/components/domains/chat/index.ts @@ -1,4 +1,8 @@ export { default as CategoryChats } from "./CategoryChats/CategoryChats"; +export { + default as ChatCategories, + ChatCategoriesSkeleton, +} from "./ChatCategories/ChatCategories"; export { default as ChatHeader } from "./ChatHeader/ChatHeader"; export { default as ChatItemsList } from "./ChatItemsList/ChatItemsList"; export { default as ChatItemsListHeader } from "./ChatItemsListHeader/ChatItemsListHeader"; @@ -8,7 +12,16 @@ export { type ChatMessageProps, } from "./ChatMessage/ChatMessage"; export { default as ChatMessages } from "./ChatMessages/ChatMessages"; +export { + default as ChatMessagesWrapper, + ChatMessagesWrapperLoader, +} from "./ChatMessagesWrapper/ChatMessagesWrapper"; +export { default as ChatModalsWrapper } from "./ChatModalsWrapper/ChatModalsWrapper"; export { default as CorrespondenceStarted } from "./CorrespondenceStarted/CorrespondenceStarted"; +export { + CorrespondenceStartedSkeleton, + default as CorrespondenceStartedWrapper, +} from "./CorrespondenceStartedWrapper/CorrespondenceStartedWrapper"; export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal"; export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal"; export { @@ -16,5 +29,10 @@ export { type LastMessagePreviewProps, } from "./LastMessagePreview/LastMessagePreview"; export { default as MessageInput } from "./MessageInput/MessageInput"; +export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper"; export { default as NewMessages } from "./NewMessages/NewMessages"; +export { + default as NewMessagesWrapper, + NewMessagesWrapperSkeleton, +} from "./NewMessagesWrapper/NewMessagesWrapper"; export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll"; diff --git a/src/components/widgets/ChatItem/ChatItem.module.scss b/src/components/widgets/ChatItem/ChatItem.module.scss index 342b8f7..60b59df 100644 --- a/src/components/widgets/ChatItem/ChatItem.module.scss +++ b/src/components/widgets/ChatItem/ChatItem.module.scss @@ -3,6 +3,7 @@ grid-template-columns: 48px 1fr; align-items: center; gap: 12px; + min-height: 94px; .content { display: grid; @@ -12,18 +13,20 @@ gap: 12px; & > .information { + height: 100%; display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; align-items: flex-start; gap: 4px; } & > .meta { + height: 100%; display: flex; flex-direction: column; align-items: flex-end; - justify-content: center; + justify-content: flex-start; gap: 1px; & > .time { diff --git a/src/components/widgets/ChatItem/ChatItem.tsx b/src/components/widgets/ChatItem/ChatItem.tsx index 3baddf2..77f9eaf 100644 --- a/src/components/widgets/ChatItem/ChatItem.tsx +++ b/src/components/widgets/ChatItem/ChatItem.tsx @@ -17,8 +17,8 @@ import styles from "./ChatItem.module.scss"; export interface ChatItemProps { userAvatar: UserAvatarProps; name: string; - messagePreiew: LastMessagePreviewProps; - time: string; + messagePreiew: LastMessagePreviewProps | null; + time: string | null; badgeContent: React.ReactNode; className?: string; style?: React.CSSProperties; @@ -45,17 +45,19 @@ export default function ChatItem({
{name} - + {messagePreiew && }
- {time} + {time || ""} - - - {badgeContent} - - + {!!badgeContent && ( + + + {badgeContent} + + + )}
diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts index ff13034..3d76c59 100644 --- a/src/components/widgets/index.ts +++ b/src/components/widgets/index.ts @@ -2,6 +2,7 @@ export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen"; export { default as BlurComponent } from "./BlurComponent/BlurComponent"; export { default as ChatItem, type ChatItemProps } from "./ChatItem/ChatItem"; +export { default as Chips } from "./Chips/Chips"; export { default as DatePicker } from "./DatePicker/DatePicker"; export { default as Horoscope } from "./Horoscope/Horoscope"; export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation"; diff --git a/src/entities/chats/actions.ts b/src/entities/chats/actions.ts new file mode 100644 index 0000000..fdee54b --- /dev/null +++ b/src/entities/chats/actions.ts @@ -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 +> { + try { + const response = await http.post( + 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> { + try { + const response = await http.get( + 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 }; + } +} diff --git a/src/entities/chats/api.ts b/src/entities/chats/api.ts new file mode 100644 index 0000000..8d0f2b8 --- /dev/null +++ b/src/entities/chats/api.ts @@ -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 => { + return http.post( + API_ROUTES.createAllChats(), + {}, + { + tags: ["chats", "create-all"], + schema: CreateAllChatsResponseSchema, + revalidate: 0, + } + ); +}; + +export const createChat = async ( + assistantId: string +): Promise => { + return http.post( + API_ROUTES.createChat(assistantId), + {}, + { + tags: ["chats", "create"], + schema: CreateChatResponseSchema, + revalidate: 0, + } + ); +}; + +export const getChatsList = async (): Promise => { + return http.get(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 => { + return http.get( + API_ROUTES.getChatMessages(chatId), + { + tags: ["chats", chatId, "messages"], + schema: GetChatMessagesResponseSchema, + query: { limit: params.limit, page: params.page }, + revalidate: 0, + } + ); +}; diff --git a/src/entities/chats/loaders.ts b/src/entities/chats/loaders.ts new file mode 100644 index 0000000..f3e6b6a --- /dev/null +++ b/src/entities/chats/loaders.ts @@ -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); diff --git a/src/entities/chats/types.ts b/src/entities/chats/types.ts new file mode 100644 index 0000000..e7085d9 --- /dev/null +++ b/src/entities/chats/types.ts @@ -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; +export type ICategorizedChats = z.infer; +export type ICreateAllChatsResponse = z.infer< + typeof CreateAllChatsResponseSchema +>; +export type ICreateChatResponse = z.infer; +export type IGetChatsListResponse = z.infer; +export type IChatMessage = z.infer; +export type IGetChatMessagesResponse = z.infer< + typeof GetChatMessagesResponseSchema +>; + +export { + ChatMessageSchema, + CreateAllChatsResponseSchema, + CreateChatResponseSchema, + GetChatMessagesResponseSchema, + GetChatsListResponseSchema, +}; diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts index 9c2147d..e4c097f 100644 --- a/src/entities/payment/types.ts +++ b/src/entities/payment/types.ts @@ -17,6 +17,7 @@ export type CheckoutResponse = z.infer; export const PaymentInfoSchema = z.object({ productId: z.string(), key: z.string(), + isAutoTopUp: z.boolean().optional(), }); export type PaymentInfo = z.infer; diff --git a/src/entities/user/actions.ts b/src/entities/user/actions.ts new file mode 100644 index 0000000..e8ac3b3 --- /dev/null +++ b/src/entities/user/actions.ts @@ -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> { + try { + const response = await http.get(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 }; + } +} diff --git a/src/entities/user/loaders.ts b/src/entities/user/loaders.ts index 142326a..a25e6e8 100644 --- a/src/entities/user/loaders.ts +++ b/src/entities/user/loaders.ts @@ -5,3 +5,5 @@ import { getMe } from "./api"; export const loadMe = cache(getMe); export const loadUser = cache(() => loadMe().then(d => d.user)); + +export const loadUserId = cache(() => loadUser().then(d => d._id)); diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts new file mode 100644 index 0000000..a64a092 --- /dev/null +++ b/src/hooks/chats/useChatSocket.ts @@ -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(() => + options.initialMessages ? options.initialMessages.map(mapApiMessage) : [] + ); + const [page, setPage] = useState(1); + const [totalCount, _setTotalCount] = useState( + options.initialTotal ?? null + ); + const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const [balance, setBalance] = useState(null); + const [session, setSession] = useState(null); + const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false); + // const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false); + const [isSessionExpired, setIsSessionExpired] = useState(false); + const [refillModals, setRefillModals] = useState(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(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(); + + 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, + ] + ); +}; diff --git a/src/hooks/chats/useChatsInitialization.ts b/src/hooks/chats/useChatsInitialization.ts new file mode 100644 index 0000000..ce60d88 --- /dev/null +++ b/src/hooks/chats/useChatsInitialization.ts @@ -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(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, + }; +}; diff --git a/src/hooks/socket/useSocketEvent.ts b/src/hooks/socket/useSocketEvent.ts new file mode 100644 index 0000000..fd21000 --- /dev/null +++ b/src/hooks/socket/useSocketEvent.ts @@ -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( + 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]); +} diff --git a/src/providers/chat-provider.tsx b/src/providers/chat-provider.tsx new file mode 100644 index 0000000..7a843a8 --- /dev/null +++ b/src/providers/chat-provider.tsx @@ -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 { + messagesWrapperRef: React.RefObject; + scrollToBottom: () => void; +} + +const ChatContext = createContext(null); + +export function useChat() { + const ctx = useContext(ChatContext); + if (!ctx) { + throw new Error("useChat must be used within "); + } + 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(null); + + const scrollToBottom = useCallback(() => { + if (messagesWrapperRef.current) { + messagesWrapperRef.current.scrollTo({ + top: messagesWrapperRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, []); + + return ( + + {children} + + ); +} diff --git a/src/providers/chat-store-provider.tsx b/src/providers/chat-store-provider.tsx new file mode 100644 index 0000000..7ebebbb --- /dev/null +++ b/src/providers/chat-store-provider.tsx @@ -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; + +export const ChatStoreContext = createContext( + undefined +); + +export interface ChatStoreProviderProps { + children: ReactNode; +} + +export const ChatStoreProvider = ({ children }: ChatStoreProviderProps) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = createChatStore(); + } + + return ( + + {children} + + ); +}; + +export const useChatStore = (selector: (store: ChatStore) => T): T => { + const chatStoreContext = useContext(ChatStoreContext); + + if (!chatStoreContext) { + throw new Error(`useChatStore must be used within ChatStoreProvider`); + } + + return useStore(chatStoreContext, selector); +}; diff --git a/src/providers/chats-initialization-provider.tsx b/src/providers/chats-initialization-provider.tsx new file mode 100644 index 0000000..1dc96f1 --- /dev/null +++ b/src/providers/chats-initialization-provider.tsx @@ -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(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 ( + + {children} + + ); +}; diff --git a/src/providers/socket-provider.tsx b/src/providers/socket-provider.tsx new file mode 100644 index 0000000..e7e8a4e --- /dev/null +++ b/src/providers/socket-provider.tsx @@ -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}; +} diff --git a/src/providers/user-provider.tsx b/src/providers/user-provider.tsx new file mode 100644 index 0000000..b623de3 --- /dev/null +++ b/src/providers/user-provider.tsx @@ -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(undefined); + +interface UserProviderProps { + user: IUser | null; + children: ReactNode; +} + +export function UserProvider({ user, children }: UserProviderProps) { + return ( + {children} + ); +} + +export function useUser() { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error("useUser must be used within a UserProvider"); + } + return context; +} diff --git a/src/services/chats/index.ts b/src/services/chats/index.ts new file mode 100644 index 0000000..9115aa1 --- /dev/null +++ b/src/services/chats/index.ts @@ -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); + }, +}; diff --git a/src/services/socket/events.ts b/src/services/socket/events.ts new file mode 100644 index 0000000..86d7cf2 --- /dev/null +++ b/src/services/socket/events.ts @@ -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 { + status: string; + data: T; +} + +export interface ServerToClientEvents { + chat_joined: (data: ServerToClientEventsBaseData) => void; + chat_left: (data: ServerToClientEventsBaseData) => void; + receive_message: (data: IMessage[]) => void; + current_balance: ( + data: ServerToClientEventsBaseData + ) => void; + balance_updated: ( + data: ServerToClientEventsBaseData + ) => void; + session_started: ( + data: ServerToClientEventsBaseData + ) => void; + session_ended: (data: ServerToClientEventsBaseData) => void; + show_refill_modals: (data: IRefillModals) => void; +} diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts new file mode 100644 index 0000000..a4635bc --- /dev/null +++ b/src/services/socket/index.ts @@ -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 | null; + status: ESocketStatus; + error: string | null; + reconnectAttempt: number; + // queue: { event: keyof ClientToServerEvents; args: unknown[] }[]; + reconnectTimeoutId?: ReturnType; +} + +interface SocketActions { + connect: (userId: string) => void; + disconnect: () => void; + emit: ( + event: E, + ...args: Parameters + ) => void; + clearReconnectTimer: () => void; + + // enqueue: ( + // event: E, + // ...args: Parameters + // ) => 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()( + 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 = 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); diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index 327fd9d..e51ea81 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -32,4 +32,12 @@ export const API_ROUTES = { // session funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2), + + // chats + createAllChats: () => createRoute(["chats", "create-all"]), + createChat: (assistantId: string) => + createRoute(["chats", "create", assistantId]), + getChatsList: () => createRoute(["chats", "list"]), + getChatMessages: (chatId: string) => + createRoute(["chats", chatId, "messages"]), }; diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index 15cc6bb..394e25c 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -14,3 +14,27 @@ export const formatTime = (date: string | null) => { minute: "2-digit", }); }; + +export const formatSecondsToHHMMSS = ( + seconds: number, + availableValues: Partial< + Record<"isHours" | "isMinutes" | "isSeconds", boolean> + > +) => { + const { + isHours = true, + isMinutes = true, + isSeconds = true, + } = availableValues; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return [ + isHours && String(hours).padStart(2, "0"), + isMinutes && String(minutes).padStart(2, "0"), + isSeconds && String(secs).padStart(2, "0"), + ] + .filter(Boolean) + .join(":"); +}; diff --git a/src/stores/chat-store.ts b/src/stores/chat-store.ts new file mode 100644 index 0000000..b1382e3 --- /dev/null +++ b/src/stores/chat-store.ts @@ -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()( + persist( + set => ({ + ...initState, + setCurrentChat: (chat: IChat) => set({ currentChat: chat }), + setIsAutoTopUp: (isAutoTopUp: boolean) => set({ isAutoTopUp }), + clearChatData: () => set(initialState), + }), + { name: "chat-storage" } + ) + ); +};