Добавил блюр на сообщения и блокировку при нулевом балансе

This commit is contained in:
dev.daminik00 2025-09-19 00:11:23 +02:00
parent 9fbfa63fea
commit c5002e1a7a
23 changed files with 205 additions and 32 deletions

View File

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

View File

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

View File

@ -10,4 +10,5 @@
align-self: flex-end;
margin-left: auto;
}
}

View File

@ -1,20 +1,20 @@
"use client";
import { useEffect } from "react";
import clsx from "clsx";
import { useEffect } from "react";
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 { formatTime } from "@/shared/utils/date";
import styles from "./ChatMessage.module.scss";
import MessageBubble from "./MessageBubble/MessageBubble";
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;
@ -31,9 +31,22 @@ export interface 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 shouldBlur =
!isOwn && // Блюрим только сообщения ассистента (НЕ пользователя)
message.sentWithZeroBalance === true &&
(balance?.balance ?? 0) <= 0;
const handleBlurClick = () => {
if (message.chatId) {
showRefillModal(message.chatId);
}
};
useEffect(() => {
if (
!!message.id &&
@ -50,7 +63,12 @@ export default function ChatMessage({ message }: ChatMessageProps) {
<div className={clsx(styles.message, isOwn && styles.own)}>
<MessageBubble isOwn={isOwn}>
{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 />}

View File

@ -6,4 +6,15 @@
&.own {
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;
isOwn: boolean;
className?: string;
isBlurred?: boolean;
onBlurClick?: () => void;
}
export default function MessageText({
text,
isOwn,
className,
isBlurred = false,
onBlurClick,
}: MessageTextProps) {
const handleClick = () => {
if (isBlurred && onBlurClick) {
onBlurClick();
}
};
return (
<div
onClick={handleClick}
style={isBlurred ? { cursor: 'pointer' } : undefined}
>
<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 { useChat } from "@/providers/chat-provider";
import { useToast } from "@/providers/toast-provider";
import { useSocketEmit } from "@/services/socket";
import { RefillOptionsModal, RefillTimerModal } from "..";
export default function ChatModalsWrapper() {
const t = useTranslations("Chat");
const { refillModals } = useChat();
const { refillModals, clearRefillModals } = useChat();
const { addToast } = useToast();
const emit = useSocketEmit();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalChild, setModalChild] = useState<
"refill-timer" | "refill-options" | null
>(null);
const [isClosing, setIsClosing] = useState(false);
// Теперь hasShownTimerModal приходит с сервера
const hasShownTimerModal = refillModals?.hasShownTimerModal ?? false;
const handleModalClose = () => {
setIsClosing(true);
setIsModalOpen(false);
const timeout = setTimeout(() => {
setModalChild(null);
setIsClosing(false);
clearRefillModals(); // Очищаем состояние refillModals
}, 300);
return () => clearTimeout(timeout);
};
const handleTimerModalClose = () => {
// При закрытии крестика в timer modal - переходим к options modal
setModalChild("refill-options");
};
useEffect(() => {
if (!!refillModals?.oneClick) {
// Не обрабатываем новые события если модальное окно в процессе закрытия
if (isClosing) return;
if (!!refillModals?.oneClick && !hasShownTimerModal) {
setIsModalOpen(true);
return setModalChild("refill-timer");
setModalChild("refill-timer");
// Подтверждаем серверу что показали timer modal
emit("confirm_timer_modal_shown");
return;
}
if (!!refillModals?.products) {
setIsModalOpen(true);
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 = () => {
handleModalClose();
@ -54,8 +81,9 @@ export default function ChatModalsWrapper() {
return (
<ModalSheet
open={isModalOpen}
onClose={handleModalClose}
onClose={modalChild === "refill-timer" ? handleTimerModalClose : handleModalClose}
variant={modalChild === "refill-timer" ? "gray" : "white"}
disableBackdropClose={true}
>
{modalChild === "refill-timer" && !!refillModals?.oneClick && (
<RefillTimerModal

View File

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

View File

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

View File

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

View File

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

View File

@ -9,14 +9,15 @@ import styles from "./MessageInput.module.scss";
interface MessageInputProps {
onSend: (message: string) => void;
disabled?: boolean;
}
export default function MessageInput({ onSend }: MessageInputProps) {
export default function MessageInput({ onSend, disabled = false }: MessageInputProps) {
const t = useTranslations("Chat");
const [message, setMessage] = useState("");
const handleSend = () => {
if (message.trim()) {
if (message.trim() && !disabled) {
onSend(message.trim());
setMessage("");
}
@ -57,7 +58,7 @@ export default function MessageInput({ onSend }: MessageInputProps) {
/>
<Button
onClick={handleSend}
disabled={!message.trim()}
disabled={!message.trim() || disabled}
aria-label="Send"
className={styles.sendButton}
>

View File

@ -7,12 +7,15 @@ import { MessageInput } from "..";
import styles from "./MessageInputWrapper.module.scss";
export default function MessageInputWrapper() {
const { send } = useChat();
const { send, balance } = useChat();
// Блокируем отправку сообщений при нулевом или отрицательном балансе
const disabled = (balance?.balance ?? 0) <= 0;
return (
<div className={styles.container}>
<div className={styles.inputWrapper}>
<MessageInput onSend={send} />
<MessageInput onSend={send} disabled={disabled} />
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ const ChatMessageSchema = z.object({
text: z.string().optional(),
isLast: z.boolean().optional(),
suggestions: z.array(z.string()).optional(),
sentWithZeroBalance: z.boolean().optional(),
});
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, chatId]);
const clearRefillModals = useCallback(() => {
setRefillModals(null);
}, []);
// Auto top-up: silent flow (no UI prompt)
const autoTopUpInProgressRef = useRef(false);
// Timer for delayed unlock after error
@ -352,6 +356,7 @@ export const useChatSocket = (
read,
startSession,
endSession,
clearRefillModals,
isLoadingSelfMessage,
isLoadingAdvisorMessage,
@ -380,6 +385,7 @@ export const useChatSocket = (
read,
startSession,
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[];
hasShownTimerModal?: boolean;
}
export interface IAutoTopUpRequest {
@ -63,6 +64,8 @@ export interface ClientToServerEvents {
end_session: (data: { chatId: string }) => void;
fetch_balance: (data: { chatId: string }) => void;
deposit: (data: { amount: number }) => void;
confirm_timer_modal_shown: () => void;
request_refill_modal: (data: { chatId: string }) => void;
}
export interface ServerToClientEventsBaseData<T> {