commit
60edce97fe
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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 />}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
27
src/hooks/balance/useBalance.ts
Normal file
27
src/hooks/balance/useBalance.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
14
src/hooks/refill/useShowRefillModal.ts
Normal file
14
src/hooks/refill/useShowRefillModal.ts
Normal 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 };
|
||||||
|
};
|
||||||
@ -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> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user