Merge pull request #57 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-09-19 01:18:17 +02:00 committed by GitHub
commit 60edce97fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 282 additions and 36 deletions

View File

@ -13,11 +13,13 @@ import styles from "./CategoryChats.module.scss";
interface CategoryChatsProps { interface CategoryChatsProps {
chats: IChat[]; chats: IChat[];
maxVisibleChats?: number; maxVisibleChats?: number;
currentBalance?: number;
} }
export default function CategoryChats({ export default function CategoryChats({
chats, chats,
maxVisibleChats = 3, maxVisibleChats = 3,
currentBalance = 0,
}: CategoryChatsProps) { }: CategoryChatsProps) {
const router = useRouter(); const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat); const setCurrentChat = useChatStore(state => state.setCurrentChat);
@ -40,7 +42,9 @@ export default function CategoryChats({
message: { message: {
type: chat.lastMessage.type, type: chat.lastMessage.type,
content: chat.lastMessage.text, content: chat.lastMessage.text,
sentWithZeroBalance: chat.lastMessage.sentWithZeroBalance,
}, },
currentBalance: currentBalance,
} }
: null : null
} }

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { Chips } from "@/components/widgets"; import { Chips } from "@/components/widgets";
import { useBalance } from "@/hooks/balance/useBalance";
import { useChats } from "@/providers/chats-provider"; import { useChats } from "@/providers/chats-provider";
import { CategoryChats, ChatItemsList } from ".."; import { CategoryChats, ChatItemsList } from "..";
@ -12,6 +13,7 @@ const MAX_HIDE_VISIBLE_COUNT = 3;
export default function ChatCategories() { export default function ChatCategories() {
const { categorizedChats } = useChats(); const { categorizedChats } = useChats();
const { balance } = useBalance();
const [activeChip, setActiveChip] = useState<string>("All"); const [activeChip, setActiveChip] = useState<string>("All");
const [maxVisibleChats, setMaxVisibleChats] = useState< const [maxVisibleChats, setMaxVisibleChats] = useState<
@ -59,6 +61,7 @@ export default function ChatCategories() {
<CategoryChats <CategoryChats
chats={categorizedChats[key]} chats={categorizedChats[key]}
maxVisibleChats={maxVisibleChats[key] ?? MAX_HIDE_VISIBLE_COUNT} maxVisibleChats={maxVisibleChats[key] ?? MAX_HIDE_VISIBLE_COUNT}
currentBalance={balance}
/> />
</ChatItemsList> </ChatItemsList>
))} ))}

View File

@ -3,7 +3,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { IChatMessage } from "@/entities/chats/types"; import type { IChatMessage } from "@/entities/chats/types";
import { useShowRefillModal } from "@/hooks/refill/useShowRefillModal";
import { useChat } from "@/providers/chat-provider"; import { useChat } from "@/providers/chat-provider";
import { formatTime } from "@/shared/utils/date"; import { formatTime } from "@/shared/utils/date";
@ -31,9 +32,22 @@ export interface ChatMessageProps {
} }
export default function ChatMessage({ message }: ChatMessageProps) { export default function ChatMessage({ message }: ChatMessageProps) {
const { isConnected, read } = useChat(); const { isConnected, read, balance } = useChat();
const { showRefillModal } = useShowRefillModal();
const isOwn = message.role === "user"; const isOwn = message.role === "user";
// Применяем блюр к ОТВЕТАМ АССИСТЕНТА если они были отправлены при нулевом балансе И текущий баланс все еще нулевой
const shouldBlur =
!isOwn && // Блюрим только сообщения ассистента (НЕ пользователя)
message.sentWithZeroBalance === true &&
(balance?.balance ?? 0) <= 0;
const handleBlurClick = () => {
if (message.chatId) {
showRefillModal(message.chatId);
}
};
useEffect(() => { useEffect(() => {
if ( if (
!!message.id && !!message.id &&
@ -50,7 +64,12 @@ export default function ChatMessage({ message }: ChatMessageProps) {
<div className={clsx(styles.message, isOwn && styles.own)}> <div className={clsx(styles.message, isOwn && styles.own)}>
<MessageBubble isOwn={isOwn}> <MessageBubble isOwn={isOwn}>
{message.type === "text" && message.id !== "typing" && ( {message.type === "text" && message.id !== "typing" && (
<MessageText text={message.text} isOwn={isOwn} /> <MessageText
text={message.text}
isOwn={isOwn}
isBlurred={shouldBlur}
onBlurClick={handleBlurClick}
/>
)} )}
{message.id === "typing" && <MessageTyping />} {message.id === "typing" && <MessageTyping />}

View File

@ -6,4 +6,17 @@
&.own { &.own {
color: #ffffff; color: #ffffff;
} }
&.blurred {
filter: blur(4px);
opacity: 0.6;
transition:
filter 0.3s ease,
opacity 0.3s ease;
pointer-events: none;
user-select: none; // Запрещаем выделение текста
-webkit-user-select: none; // Safari
-moz-user-select: none; // Firefox
-ms-user-select: none; // IE/Edge
}
} }

View File

@ -8,24 +8,40 @@ interface MessageTextProps {
text?: string; text?: string;
isOwn: boolean; isOwn: boolean;
className?: string; className?: string;
isBlurred?: boolean;
onBlurClick?: () => void;
} }
export default function MessageText({ export default function MessageText({
text, text,
isOwn, isOwn,
className, className,
isBlurred = false,
onBlurClick,
}: MessageTextProps) { }: MessageTextProps) {
const handleClick = () => {
if (isBlurred && onBlurClick) {
onBlurClick();
}
};
return ( return (
<Typography <div
as="p" onClick={handleClick}
align="left" style={isBlurred ? { cursor: "pointer" } : undefined}
className={clsx(
styles.text,
isOwn ? styles.own : styles.other,
className
)}
> >
{text} <Typography
</Typography> as="p"
align="left"
className={clsx(
styles.text,
isOwn ? styles.own : styles.other,
isBlurred && styles.blurred,
className
)}
>
{text}
</Typography>
</div>
); );
} }

View File

@ -6,38 +6,65 @@ import { useTranslations } from "next-intl";
import { ModalSheet } from "@/components/ui"; import { ModalSheet } from "@/components/ui";
import { useChat } from "@/providers/chat-provider"; import { useChat } from "@/providers/chat-provider";
import { useToast } from "@/providers/toast-provider"; import { useToast } from "@/providers/toast-provider";
import { useSocketEmit } from "@/services/socket";
import { RefillOptionsModal, RefillTimerModal } from ".."; import { RefillOptionsModal, RefillTimerModal } from "..";
export default function ChatModalsWrapper() { export default function ChatModalsWrapper() {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const { refillModals } = useChat(); const { refillModals, clearRefillModals } = useChat();
const { addToast } = useToast(); const { addToast } = useToast();
const emit = useSocketEmit();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChild, setModalChild] = useState< const [modalChild, setModalChild] = useState<
"refill-timer" | "refill-options" | null "refill-timer" | "refill-options" | null
>(null); >(null);
const [isClosing, setIsClosing] = useState(false);
// Теперь hasShownTimerModal приходит с сервера
const hasShownTimerModal = refillModals?.hasShownTimerModal ?? false;
const handleModalClose = () => { const handleModalClose = () => {
setIsClosing(true);
setIsModalOpen(false); setIsModalOpen(false);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setModalChild(null); setModalChild(null);
setIsClosing(false);
clearRefillModals(); // Очищаем состояние refillModals
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}; };
const handleTimerModalClose = () => {
// При закрытии крестика в timer modal - переходим к options modal
setModalChild("refill-options");
};
useEffect(() => { useEffect(() => {
if (!!refillModals?.oneClick) { // Не обрабатываем новые события если модальное окно в процессе закрытия
if (isClosing) return;
if (!!refillModals?.oneClick && !hasShownTimerModal) {
setIsModalOpen(true); setIsModalOpen(true);
return setModalChild("refill-timer"); setModalChild("refill-timer");
// Подтверждаем серверу что показали timer modal
emit("confirm_timer_modal_shown");
return;
} }
if (!!refillModals?.products) { if (!!refillModals?.products) {
setIsModalOpen(true); setIsModalOpen(true);
return setModalChild("refill-options"); return setModalChild("refill-options");
} }
}, [refillModals]); // Если получили событие повторно и timer modal уже показывался,
// сразу показываем options modal
if (!!refillModals?.oneClick && hasShownTimerModal) {
if (!isModalOpen) {
setIsModalOpen(true);
return setModalChild("refill-options");
}
}
}, [refillModals, hasShownTimerModal, isModalOpen, emit, isClosing]);
const handlePaymentSuccess = () => { const handlePaymentSuccess = () => {
handleModalClose(); handleModalClose();
@ -54,8 +81,11 @@ export default function ChatModalsWrapper() {
return ( return (
<ModalSheet <ModalSheet
open={isModalOpen} open={isModalOpen}
onClose={handleModalClose} onClose={
modalChild === "refill-timer" ? handleTimerModalClose : handleModalClose
}
variant={modalChild === "refill-timer" ? "gray" : "white"} variant={modalChild === "refill-timer" ? "gray" : "white"}
disableBackdropClose={true}
> >
{modalChild === "refill-timer" && !!refillModals?.oneClick && ( {modalChild === "refill-timer" && !!refillModals?.oneClick && (
<RefillTimerModal <RefillTimerModal

View File

@ -20,14 +20,16 @@ const getTopPositionItem = (index: number) => {
interface CorrespondenceStartedProps { interface CorrespondenceStartedProps {
chats: IChat[]; chats: IChat[];
isVisibleAll?: boolean; isVisibleAll: boolean;
maxHideVisibleCount?: number; maxHideVisibleCount?: number;
currentBalance?: number;
} }
export default function CorrespondenceStarted({ export default function CorrespondenceStarted({
chats, chats,
isVisibleAll = false, isVisibleAll = false,
maxHideVisibleCount = 3, maxHideVisibleCount = 3,
currentBalance = 0,
}: CorrespondenceStartedProps) { }: CorrespondenceStartedProps) {
const router = useRouter(); const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat); const setCurrentChat = useChatStore(state => state.setCurrentChat);
@ -61,7 +63,9 @@ export default function CorrespondenceStarted({
message: { message: {
type: chat.lastMessage.type, type: chat.lastMessage.type,
content: chat.lastMessage.text, content: chat.lastMessage.text,
sentWithZeroBalance: chat.lastMessage.sentWithZeroBalance,
}, },
currentBalance: currentBalance,
} }
: null : null
} }

View File

@ -3,6 +3,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { useBalance } from "@/hooks/balance/useBalance";
import { useAppUiStore } from "@/providers/app-ui-store-provider"; import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider"; import { useChats } from "@/providers/chats-provider";
@ -11,6 +12,7 @@ import { ChatItemsList, CorrespondenceStarted } from "..";
export default function CorrespondenceStartedWrapper() { export default function CorrespondenceStartedWrapper() {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const { startedChats } = useChats(); const { startedChats } = useChats();
const { balance } = useBalance();
const { isVisibleAll } = useAppUiStore( const { isVisibleAll } = useAppUiStore(
state => state.chats.correspondenceStarted state => state.chats.correspondenceStarted
@ -41,6 +43,7 @@ export default function CorrespondenceStartedWrapper() {
<CorrespondenceStarted <CorrespondenceStarted
chats={startedChats} chats={startedChats}
isVisibleAll={isVisibleAll} isVisibleAll={isVisibleAll}
currentBalance={balance}
/> />
</ChatItemsList> </ChatItemsList>
)} )}

View File

@ -5,6 +5,7 @@ import { useLocale } from "next-intl";
import NewMessages from "@/components/domains/chat/NewMessages/NewMessages"; import NewMessages from "@/components/domains/chat/NewMessages/NewMessages";
import ViewAll from "@/components/domains/chat/ViewAll/ViewAll"; import ViewAll from "@/components/domains/chat/ViewAll/ViewAll";
import { useBalance } from "@/hooks/balance/useBalance";
import { useAppUiStore } from "@/providers/app-ui-store-provider"; import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider"; import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
@ -14,6 +15,7 @@ import styles from "./GlobalNewMessagesBanner.module.scss";
export default function GlobalNewMessagesBanner() { export default function GlobalNewMessagesBanner() {
const { unreadChats } = useChats(); const { unreadChats } = useChats();
const { balance } = useBalance();
// Exclude banner on chat-related, settings (profile), and retention funnel pages // Exclude banner on chat-related, settings (profile), and retention funnel pages
const pathname = usePathname(); const pathname = usePathname();
@ -39,7 +41,11 @@ export default function GlobalNewMessagesBanner() {
onClick={() => setHomeNewMessages({ isVisibleAll: !isVisibleAll })} onClick={() => setHomeNewMessages({ isVisibleAll: !isVisibleAll })}
/> />
)} )}
<NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} /> <NewMessages
chats={unreadChats}
isVisibleAll={isVisibleAll}
currentBalance={balance}
/>
</div> </div>
); );
} }

View File

@ -17,4 +17,16 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
&.blurred {
filter: blur(4px);
opacity: 0.6;
transition:
filter 0.3s ease,
opacity 0.3s ease;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
} }

View File

@ -8,18 +8,25 @@ export interface LastMessagePreviewProps {
message: { message: {
type: "text" | "voice" | "image"; type: "text" | "voice" | "image";
content?: string; content?: string;
sentWithZeroBalance?: boolean;
}; };
isTyping?: boolean; isTyping?: boolean;
isRead?: boolean; isRead?: boolean;
currentBalance?: number;
} }
export default function LastMessagePreview({ export default function LastMessagePreview({
message, message,
isTyping, isTyping,
isRead, isRead,
currentBalance = 0,
}: LastMessagePreviewProps) { }: LastMessagePreviewProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
// Определяем нужно ли блюрить сообщение
const shouldBlur =
message.sentWithZeroBalance === true && currentBalance <= 0;
const getMessageIcon = () => { const getMessageIcon = () => {
switch (message.type) { switch (message.type) {
case "voice": case "voice":
@ -72,7 +79,7 @@ export default function LastMessagePreview({
size="sm" size="sm"
color="secondary" color="secondary"
align="left" align="left"
className={styles.text} className={`${styles.text} ${shouldBlur ? styles.blurred : ""}`}
> >
{getMessageText()} {getMessageText()}
</Typography> </Typography>

View File

@ -20,3 +20,18 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.sendButtonWrapper {
position: relative;
width: 40px;
height: 40px;
}
.disabledOverlay {
position: absolute;
inset: 0;
border-radius: 50%;
z-index: 1;
background: transparent;
cursor: pointer;
}

View File

@ -9,14 +9,20 @@ import styles from "./MessageInput.module.scss";
interface MessageInputProps { interface MessageInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
disabled?: boolean;
onDisabledClick?: () => void;
} }
export default function MessageInput({ onSend }: MessageInputProps) { export default function MessageInput({
onSend,
disabled = false,
onDisabledClick,
}: MessageInputProps) {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const handleSend = () => { const handleSend = () => {
if (message.trim()) { if (message.trim() && !disabled) {
onSend(message.trim()); onSend(message.trim());
setMessage(""); setMessage("");
} }
@ -24,6 +30,12 @@ export default function MessageInput({ onSend }: MessageInputProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
// If disabled (e.g., zero balance), open refill modal instead of sending
if (!e.ctrlKey && disabled) {
e.preventDefault();
onDisabledClick?.();
return;
}
if (e.ctrlKey) { if (e.ctrlKey) {
// Ctrl + Enter - добавляем перенос строки // Ctrl + Enter - добавляем перенос строки
e.preventDefault(); e.preventDefault();
@ -55,14 +67,24 @@ export default function MessageInput({ onSend }: MessageInputProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
maxRows={5} maxRows={5}
/> />
<Button <div className={styles.sendButtonWrapper}>
onClick={handleSend} {/* Capture clicks when disabled to open refill modal */}
disabled={!message.trim()} {disabled && typeof onDisabledClick === "function" && (
aria-label="Send" <div
className={styles.sendButton} className={styles.disabledOverlay}
> onClick={onDisabledClick}
<Icon name={IconName.PaperAirplane} size={{ height: 14, width: 14 }} /> aria-hidden
</Button> />
)}
<Button
onClick={handleSend}
disabled={!message.trim() || disabled}
aria-label="Send"
className={styles.sendButton}
>
<Icon name={IconName.PaperAirplane} size={{ height: 14, width: 14 }} />
</Button>
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { useShowRefillModal } from "@/hooks/refill/useShowRefillModal";
import { useChat } from "@/providers/chat-provider"; import { useChat } from "@/providers/chat-provider";
import { MessageInput } from ".."; import { MessageInput } from "..";
@ -7,12 +8,26 @@ import { MessageInput } from "..";
import styles from "./MessageInputWrapper.module.scss"; import styles from "./MessageInputWrapper.module.scss";
export default function MessageInputWrapper() { export default function MessageInputWrapper() {
const { send } = useChat(); const { send, balance, chatId } = useChat();
const { showRefillModal } = useShowRefillModal();
// Блокируем отправку сообщений при нулевом или отрицательном балансе
const disabled = (balance?.balance ?? 0) <= 0;
const handleDisabledClick = () => {
if (disabled && chatId) {
showRefillModal(chatId);
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<MessageInput onSend={send} /> <MessageInput
onSend={send}
disabled={disabled}
onDisabledClick={handleDisabledClick}
/>
</div> </div>
</div> </div>
); );

View File

@ -21,12 +21,14 @@ interface NewMessagesProps {
chats: IChat[]; chats: IChat[];
isVisibleAll: boolean; isVisibleAll: boolean;
maxHideVisibleCount?: number; maxHideVisibleCount?: number;
currentBalance?: number;
} }
export default function NewMessages({ export default function NewMessages({
chats, chats,
isVisibleAll = false, isVisibleAll = false,
maxHideVisibleCount = 3, maxHideVisibleCount = 3,
currentBalance = 0,
}: NewMessagesProps) { }: NewMessagesProps) {
const router = useRouter(); const router = useRouter();
const setCurrentChat = useChatStore(state => state.setCurrentChat); const setCurrentChat = useChatStore(state => state.setCurrentChat);
@ -60,7 +62,9 @@ export default function NewMessages({
message: { message: {
type: chat.lastMessage.type, type: chat.lastMessage.type,
content: chat.lastMessage.text, content: chat.lastMessage.text,
sentWithZeroBalance: chat.lastMessage.sentWithZeroBalance,
}, },
currentBalance: currentBalance,
} }
: null : null
} }

View File

@ -3,6 +3,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { useBalance } from "@/hooks/balance/useBalance";
import { useAppUiStore } from "@/providers/app-ui-store-provider"; import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider"; import { useChats } from "@/providers/chats-provider";
@ -11,6 +12,7 @@ import { ChatItemsList, NewMessages } from "..";
export default function NewMessagesWrapper() { export default function NewMessagesWrapper() {
const t = useTranslations("Chat"); const t = useTranslations("Chat");
const { unreadChats } = useChats(); const { unreadChats } = useChats();
const { balance } = useBalance();
const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages); const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated); const hasHydrated = useAppUiStore(state => state._hasHydrated);
@ -32,7 +34,11 @@ export default function NewMessagesWrapper() {
}} }}
isVisibleViewAll={unreadChats.length > 1} isVisibleViewAll={unreadChats.length > 1}
> >
<NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} /> <NewMessages
chats={unreadChats}
isVisibleAll={isVisibleAll}
currentBalance={balance}
/>
</ChatItemsList> </ChatItemsList>
)} )}
</> </>

View File

@ -2,6 +2,7 @@
import { NewMessages, ViewAll } from "@/components/domains/chat"; import { NewMessages, ViewAll } from "@/components/domains/chat";
import { Skeleton } from "@/components/ui"; import { Skeleton } from "@/components/ui";
import { useBalance } from "@/hooks/balance/useBalance";
import { useAppUiStore } from "@/providers/app-ui-store-provider"; import { useAppUiStore } from "@/providers/app-ui-store-provider";
import { useChats } from "@/providers/chats-provider"; import { useChats } from "@/providers/chats-provider";
@ -9,6 +10,7 @@ import styles from "./NewMessagesSection.module.scss";
export default function NewMessagesSection() { export default function NewMessagesSection() {
const { unreadChats } = useChats(); const { unreadChats } = useChats();
const { balance } = useBalance();
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages); const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated); const hasHydrated = useAppUiStore(state => state._hasHydrated);
@ -29,7 +31,11 @@ export default function NewMessagesSection() {
}} }}
/> />
)} )}
<NewMessages chats={unreadChats} isVisibleAll={isVisibleAll} /> <NewMessages
chats={unreadChats}
isVisibleAll={isVisibleAll}
currentBalance={balance}
/>
</div> </div>
)} )}
</> </>

View File

@ -12,6 +12,7 @@ import styles from "./ModalSheet.module.scss";
interface ModalSheetProps extends Omit<ModalProps, "isCloseButtonVisible"> { interface ModalSheetProps extends Omit<ModalProps, "isCloseButtonVisible"> {
showCloseButton?: boolean; showCloseButton?: boolean;
variant?: "white" | "gray"; variant?: "white" | "gray";
disableBackdropClose?: boolean;
} }
export default function ModalSheet({ export default function ModalSheet({
@ -22,6 +23,7 @@ export default function ModalSheet({
modalClassName, modalClassName,
showCloseButton = true, showCloseButton = true,
variant = "white", variant = "white",
disableBackdropClose = false,
ref, ref,
}: ModalSheetProps) { }: ModalSheetProps) {
const [isOpen, setIsOpen] = useState(open); const [isOpen, setIsOpen] = useState(open);
@ -37,7 +39,13 @@ export default function ModalSheet({
return ( return (
<Modal <Modal
open={open || isOpen} open={open || isOpen}
onClose={onClose} onClose={
disableBackdropClose
? () => {
/* Backdrop close disabled */
}
: onClose
}
className={clsx(className, styles.overlay, { className={clsx(className, styles.overlay, {
[styles.closed]: !open && isOpen, [styles.closed]: !open && isOpen,
})} })}

View File

@ -11,6 +11,7 @@ const ChatMessageSchema = z.object({
text: z.string().optional(), text: z.string().optional(),
isLast: z.boolean().optional(), isLast: z.boolean().optional(),
suggestions: z.array(z.string()).optional(), suggestions: z.array(z.string()).optional(),
sentWithZeroBalance: z.boolean().optional(),
}); });
const ChatSchema = z.object({ const ChatSchema = z.object({

View File

@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
import { useSocketEvent } from "@/hooks/socket/useSocketEvent";
import { useUserBalance } from "./useUserBalance";
export const useBalance = () => {
const [socketBalance, setSocketBalance] = useState<number | null>(null);
// Получаем начальный баланс через API
const { balance: apiBalance, isLoading } = useUserBalance();
// Слушаем обновления баланса через WebSocket
useSocketEvent("balance_updated", b => {
setSocketBalance(b.data.balance);
});
// Приоритет: WebSocket > API > 0
const currentBalance = socketBalance ?? apiBalance ?? 0;
return {
balance: currentBalance,
isLoading,
};
};

View File

@ -113,6 +113,10 @@ export const useChatSocket = (
emit("fetch_balance", { chatId }); emit("fetch_balance", { chatId });
}, [emit, chatId]); }, [emit, chatId]);
const clearRefillModals = useCallback(() => {
setRefillModals(null);
}, []);
// Auto top-up: silent flow (no UI prompt) // Auto top-up: silent flow (no UI prompt)
const autoTopUpInProgressRef = useRef(false); const autoTopUpInProgressRef = useRef(false);
// Timer for delayed unlock after error // Timer for delayed unlock after error
@ -341,6 +345,7 @@ export const useChatSocket = (
return useMemo( return useMemo(
() => ({ () => ({
chatId,
messages, messages,
balance, balance,
session, session,
@ -352,6 +357,7 @@ export const useChatSocket = (
read, read,
startSession, startSession,
endSession, endSession,
clearRefillModals,
isLoadingSelfMessage, isLoadingSelfMessage,
isLoadingAdvisorMessage, isLoadingAdvisorMessage,
@ -363,6 +369,7 @@ export const useChatSocket = (
hasMoreOlderMessages, hasMoreOlderMessages,
}), }),
[ [
chatId,
messages, messages,
balance, balance,
session, session,
@ -380,6 +387,7 @@ export const useChatSocket = (
read, read,
startSession, startSession,
endSession, endSession,
clearRefillModals,
] ]
); );
}; };

View File

@ -0,0 +1,14 @@
"use client";
import { useSocketEmit } from "@/services/socket";
export const useShowRefillModal = () => {
const emit = useSocketEmit();
const showRefillModal = (chatId: string) => {
// Запрашиваем у сервера показать модальное окно пополнения
emit("request_refill_modal", { chatId });
};
return { showRefillModal };
};

View File

@ -42,6 +42,7 @@ export interface IRefillModals {
}; };
}; };
products?: IRefillModalsProduct[]; products?: IRefillModalsProduct[];
hasShownTimerModal?: boolean;
} }
export interface IAutoTopUpRequest { export interface IAutoTopUpRequest {
@ -63,6 +64,8 @@ export interface ClientToServerEvents {
end_session: (data: { chatId: string }) => void; end_session: (data: { chatId: string }) => void;
fetch_balance: (data: { chatId: string }) => void; fetch_balance: (data: { chatId: string }) => void;
deposit: (data: { amount: number }) => void; deposit: (data: { amount: number }) => void;
confirm_timer_modal_shown: () => void;
request_refill_modal: (data: { chatId: string }) => void;
} }
export interface ServerToClientEventsBaseData<T> { export interface ServerToClientEventsBaseData<T> {