diff --git a/src/api/wallet.ts b/src/api/wallet.ts index 9b9d0c5..73d57d4 100644 --- a/src/api/wallet.ts +++ b/src/api/wallet.ts @@ -1,46 +1,67 @@ -import { makeRequest } from "@frontend/kitui" -import { SendPaymentRequest, SendPaymentResponse } from "@root/model/wallet" -import { parseAxiosError } from "@root/utils/parse-error" +import { makeRequest } from "@frontend/kitui"; +import { SendPaymentRequest, SendPaymentResponse } from "@root/model/wallet"; +import { parseAxiosError } from "@root/utils/parse-error"; -const apiUrl = process.env.REACT_APP_DOMAIN + "/customer" +const apiUrl = process.env.REACT_APP_DOMAIN + "/customer"; const testPaymentBody: SendPaymentRequest = { - type: "bankCard", - amount: 15020, - currency: "RUB", - bankCard: { - number: "RUB", - expiryYear: "2021", - expiryMonth: "05", - csc: "05", - cardholder: "IVAN IVANOV", - }, - phoneNumber: "79000000000", - login: "login_test", - returnUrl: window.location.origin + "/wallet", -} + type: "bankCard", + amount: 15020, + currency: "RUB", + bankCard: { + number: "RUB", + expiryYear: "2021", + expiryMonth: "05", + csc: "05", + cardholder: "IVAN IVANOV", + }, + phoneNumber: "79000000000", + login: "login_test", + returnUrl: window.location.origin + "/wallet", +}; -export async function sendPayment( - {body = testPaymentBody, fromSquiz = false}: {body?: SendPaymentRequest, fromSquiz:boolean} -): Promise<[SendPaymentResponse | null, string?]> { - if (fromSquiz) body.returnUrl = "squiz.pena.digital/list?action=fromhub" - try { - const sendPaymentResponse = await makeRequest< +export async function sendPayment({ + body = testPaymentBody, + fromSquiz = false, +}: { + body?: SendPaymentRequest; + fromSquiz: boolean; +}): Promise<[SendPaymentResponse | null, string?]> { + if (fromSquiz) body.returnUrl = "squiz.pena.digital/list?action=fromhub"; + try { + const sendPaymentResponse = await makeRequest< SendPaymentRequest, SendPaymentResponse >({ - url: apiUrl + "/wallet", - contentType: true, - method: "POST", - useToken: true, - withCredentials: false, - body, - }) + url: apiUrl + "/wallet", + contentType: true, + method: "POST", + useToken: true, + withCredentials: false, + body, + }); - return [sendPaymentResponse] - } catch (nativeError) { - const [error] = parseAxiosError(nativeError) + return [sendPaymentResponse]; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); - return [null, `Ошибка оплаты. ${error}`] - } + return [null, `Ошибка оплаты. ${error}`]; + } } + +export const sendRSPayment = async (): Promise => { + try { + await makeRequest({ + url: apiUrl + "/wallet/rspay", + method: "POST", + useToken: true, + withCredentials: false, + }); + + return null; + } catch (nativeError) { + const [error] = parseAxiosError(nativeError); + + return `Ошибка оплаты. ${error}`; + } +}; diff --git a/src/assets/bank-logo/rs-pay.png b/src/assets/bank-logo/rs-pay.png new file mode 100644 index 0000000..95270e8 Binary files /dev/null and b/src/assets/bank-logo/rs-pay.png differ diff --git a/src/components/FloatingSupportChat/Chat.tsx b/src/components/FloatingSupportChat/Chat.tsx index 707c2f4..eee11d2 100644 --- a/src/components/FloatingSupportChat/Chat.tsx +++ b/src/components/FloatingSupportChat/Chat.tsx @@ -34,6 +34,7 @@ import { createTicket, } from "@frontend/kitui"; import { sendTicketMessage, shownMessage } from "@root/api/ticket"; +import { useSSETab } from "@root/utils/hooks/useSSETab"; interface Props { open: boolean; @@ -61,6 +62,10 @@ export default function Chat({ open = false, sx }: Props) { (state) => state.unauthTicketMessageFetchState ); const chatBoxRef = useRef(null); + const { isActiveSSETab, updateSSEValue } = useSSETab( + "ticket", + addOrUpdateUnauthMessages + ); useTicketMessages({ url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages", @@ -81,11 +86,14 @@ export default function Chat({ open = false, sx }: Props) { }); useSSESubscription({ - enabled: Boolean(sessionData), + enabled: isActiveSSETab && Boolean(sessionData), url: process.env.REACT_APP_DOMAIN + `/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`, - onNewData: addOrUpdateUnauthMessages, + onNewData: (ticketMessages) => { + updateSSEValue(ticketMessages); + addOrUpdateUnauthMessages(ticketMessages); + }, onDisconnect: useCallback(() => { setUnauthIsPreventAutoscroll(false); }, []), diff --git a/src/components/ProtectedLayout.tsx b/src/components/ProtectedLayout.tsx index 1ea76db..271de1b 100644 --- a/src/components/ProtectedLayout.tsx +++ b/src/components/ProtectedLayout.tsx @@ -1,88 +1,104 @@ -import { Outlet } from "react-router-dom" -import Navbar from "./NavbarSite/Navbar" +import { Outlet } from "react-router-dom"; +import Navbar from "./NavbarSite/Navbar"; import { - Ticket, - getMessageFromFetchError, - useAllTariffsFetcher, - usePrivilegeFetcher, - useSSESubscription, - useTicketsFetcher, - useToken, -} from "@frontend/kitui" -import { updateTickets, setTicketCount, useTicketStore, setTicketsFetchState } from "@root/stores/tickets" -import { enqueueSnackbar } from "notistack" -import { updateTariffs } from "@root/stores/tariffs" -import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs" -import { setCustomTariffs } from "@root/stores/customTariffs" -import { useDiscounts } from "@root/utils/hooks/useDiscounts" -import { setDiscounts } from "@root/stores/discounts" -import { setPrivileges } from "@root/stores/privileges" + Ticket, + getMessageFromFetchError, + useAllTariffsFetcher, + usePrivilegeFetcher, + useSSESubscription, + useTicketsFetcher, + useToken, +} from "@frontend/kitui"; +import { + updateTickets, + setTicketCount, + useTicketStore, + setTicketsFetchState, +} from "@root/stores/tickets"; +import { enqueueSnackbar } from "notistack"; +import { updateTariffs } from "@root/stores/tariffs"; +import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs"; +import { setCustomTariffs } from "@root/stores/customTariffs"; +import { useDiscounts } from "@root/utils/hooks/useDiscounts"; +import { setDiscounts } from "@root/stores/discounts"; +import { setPrivileges } from "@root/stores/privileges"; import { useHistoryData } from "@root/utils/hooks/useHistoryData"; +import { useSSETab } from "@root/utils/hooks/useSSETab"; export default function ProtectedLayout() { - const token = useToken() - const ticketApiPage = useTicketStore((state) => state.apiPage) - const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage) + const token = useToken(); + const ticketApiPage = useTicketStore((state) => state.apiPage); + const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage); + const { isActiveSSETab, updateSSEValue } = useSSETab( + "auth", + (data) => { + updateTickets(data.filter((d) => Boolean(d.id))); + setTicketCount(data.length); + } + ); + useSSESubscription({ + enabled: isActiveSSETab, + url: + process.env.REACT_APP_DOMAIN + + `/heruvym/subscribe?Authorization=${token}`, + onNewData: (data) => { + updateSSEValue(data); + updateTickets(data.filter((d) => Boolean(d.id))); + setTicketCount(data.length); + }, + marker: "ticket", + }); - useSSESubscription({ - url: process.env.REACT_APP_DOMAIN + `/heruvym/subscribe?Authorization=${token}`, - onNewData: (data) => { - updateTickets(data.filter((d) => Boolean(d.id))) - setTicketCount(data.length) - }, - marker: "ticket", - }) + useTicketsFetcher({ + url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets", + ticketsPerPage, + ticketApiPage, + onSuccess: (result) => { + if (result.data) updateTickets(result.data); + setTicketCount(result.count); + }, + onError: (error: Error) => { + const message = getMessageFromFetchError(error); + if (message) enqueueSnackbar(message); + }, + onFetchStateChange: setTicketsFetchState, + }); - useTicketsFetcher({ - url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets", - ticketsPerPage, - ticketApiPage, - onSuccess: (result) => { - if (result.data) updateTickets(result.data) - setTicketCount(result.count) - }, - onError: (error: Error) => { - const message = getMessageFromFetchError(error) - if (message) enqueueSnackbar(message) - }, - onFetchStateChange: setTicketsFetchState, - }) + useAllTariffsFetcher({ + onSuccess: updateTariffs, + onError: (error) => { + const errorMessage = getMessageFromFetchError(error); + if (errorMessage) enqueueSnackbar(errorMessage); + }, + }); - useAllTariffsFetcher({ - onSuccess: updateTariffs, - onError: (error) => { - const errorMessage = getMessageFromFetchError(error) - if (errorMessage) enqueueSnackbar(errorMessage) - }, - }) + useCustomTariffs({ + onNewUser: setCustomTariffs, + onError: (error) => { + if (error) enqueueSnackbar(error); + }, + }); - useCustomTariffs({ - onNewUser: setCustomTariffs, - onError: (error) => { - if (error) enqueueSnackbar(error) - }, - }) + useDiscounts({ + onNewDiscounts: setDiscounts, + onError: (error) => { + const message = getMessageFromFetchError(error); + if (message) enqueueSnackbar(message); + }, + }); - useDiscounts({ - onNewDiscounts: setDiscounts, - onError: (error) => { - const message = getMessageFromFetchError(error) - if (message) enqueueSnackbar(message) - }, - }) + usePrivilegeFetcher({ + onSuccess: setPrivileges, + onError: (error) => { + console.log("usePrivilegeFetcher error :>> ", error); + }, + }); - usePrivilegeFetcher({ - onSuccess: setPrivileges, - onError: (error) => { - console.log("usePrivilegeFetcher error :>> ", error) - }, - }) - - useHistoryData(); + useHistoryData(); - return ( - - - - ) + return ( + + + + ); } diff --git a/src/pages/Payment/Payment.tsx b/src/pages/Payment/Payment.tsx index c14ada2..bcf7e23 100644 --- a/src/pages/Payment/Payment.tsx +++ b/src/pages/Payment/Payment.tsx @@ -1,242 +1,292 @@ import { - Box, - Button, - IconButton, - Typography, - useMediaQuery, - useTheme, -} from "@mui/material" -import ArrowBackIcon from "@mui/icons-material/ArrowBack" -import SectionWrapper from "@components/SectionWrapper" -import PaymentMethodCard from "./PaymentMethodCard" -import mastercardLogo from "../../assets/bank-logo/logo-mastercard.png" -import visaLogo from "../../assets/bank-logo/logo-visa.png" -import qiwiLogo from "../../assets/bank-logo/logo-qiwi.png" -import mirLogo from "../../assets/bank-logo/logo-mir.png" -import tinkoffLogo from "../../assets/bank-logo/logo-tinkoff.png" -import { cardShadow } from "@root/utils/theme" -import { useEffect, useLayoutEffect, useState } from "react" -import InputTextfield from "@root/components/InputTextfield" -import { sendPayment } from "@root/api/wallet" -import { getMessageFromFetchError } from "@frontend/kitui" -import { enqueueSnackbar } from "notistack" -import { currencyFormatter } from "@root/utils/currencyFormatter" -import { useLocation, useNavigate } from "react-router-dom" -import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker" + Box, + Button, + IconButton, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import SectionWrapper from "@components/SectionWrapper"; +import PaymentMethodCard from "./PaymentMethodCard"; +import mastercardLogo from "@root/assets/bank-logo/logo-mastercard.png"; +import visaLogo from "@root/assets/bank-logo/logo-visa.png"; +import qiwiLogo from "@root/assets/bank-logo/logo-qiwi.png"; +import mirLogo from "@root/assets/bank-logo/logo-mir.png"; +import tinkoffLogo from "@root/assets/bank-logo/logo-tinkoff.png"; +import rsPayLogo from "@root/assets/bank-logo/rs-pay.png"; +import { cardShadow } from "@root/utils/theme"; +import { useEffect, useLayoutEffect, useState } from "react"; +import InputTextfield from "@root/components/InputTextfield"; +import { sendPayment, sendRSPayment } from "@root/api/wallet"; +import { getMessageFromFetchError } from "@frontend/kitui"; +import { enqueueSnackbar } from "notistack"; +import { currencyFormatter } from "@root/utils/currencyFormatter"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker"; +import { useUserStore } from "@root/stores/user"; +import { VerificationStatus } from "@root/model/account"; +import { WarnModal } from "./WarnModal"; -const paymentMethods = [ - { name: "Mastercard", image: mastercardLogo }, - { name: "Visa", image: visaLogo }, - { name: "QIWI Кошелек", image: qiwiLogo }, - { name: "Мир", image: mirLogo }, - { name: "Тинькофф", image: tinkoffLogo }, -] as const +type PaymentMethod = { + label: string; + name: string; + image: string; + unpopular?: boolean; +}; -type PaymentMethod = (typeof paymentMethods)[number]["name"]; +const paymentMethods: PaymentMethod[] = [ + { label: "Mastercard", name: "mastercard", image: mastercardLogo }, + { label: "Visa", name: "visa", image: visaLogo }, + { label: "QIWI Кошелек", name: "qiwi", image: qiwiLogo }, + { label: "Мир", name: "mir", image: mirLogo }, + { label: "Тинькофф", name: "tinkoff", image: tinkoffLogo }, +]; + +type PaymentMethodType = (typeof paymentMethods)[number]["name"]; export default function Payment() { - const theme = useTheme() - const upMd = useMediaQuery(theme.breakpoints.up("md")) - const upSm = useMediaQuery(theme.breakpoints.up("sm")) - const isTablet = useMediaQuery(theme.breakpoints.down(1000)) + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const upSm = useMediaQuery(theme.breakpoints.up("sm")); + const isTablet = useMediaQuery(theme.breakpoints.down(1000)); - const [selectedPaymentMethod, setSelectedPaymentMethod] = - useState(null) - const [paymentValueField, setPaymentValueField] = useState("0") - const [paymentLink, setPaymentLink] = useState("") - const [fromSquiz, setIsFromSquiz] = useState(false) - const location = useLocation() + const [selectedPaymentMethod, setSelectedPaymentMethod] = + useState(null); + const [warnModalOpen, setWarnModalOpen] = useState(false); + const [paymentValueField, setPaymentValueField] = useState("0"); + const [paymentLink, setPaymentLink] = useState(""); + const [fromSquiz, setIsFromSquiz] = useState(false); + const location = useLocation(); + const verificationStatus = useUserStore((state) => state.verificationStatus); + const navigate = useNavigate(); - const notEnoughMoneyAmount = - (location.state?.notEnoughMoneyAmount as number) ?? 0 + const notEnoughMoneyAmount = + (location.state?.notEnoughMoneyAmount as number) ?? 0; - const paymentValue = parseFloat(paymentValueField) * 100 - - useLayoutEffect(() => { - // eslint-disable-next-line react-hooks/exhaustive-deps - setPaymentValueField((notEnoughMoneyAmount / 100).toString()) - const params = new URLSearchParams(window.location.search) - const fromSquiz = params.get("action") - if (fromSquiz === "squizpay") { - setIsFromSquiz(true) - setPaymentValueField((Number(params.get("dif") || "0") / 100).toString()) - } - history.pushState(null, document.title, "/payment"); - console.log(fromSquiz) - }, []) + const paymentValue = parseFloat(paymentValueField) * 100; - useEffect(() => { - setPaymentLink("") - }, [selectedPaymentMethod]) + useLayoutEffect(() => { + setPaymentValueField((notEnoughMoneyAmount / 100).toString()); + const params = new URLSearchParams(window.location.search); + const fromSquiz = params.get("action"); + if (fromSquiz === "squizpay") { + setIsFromSquiz(true); + setPaymentValueField((Number(params.get("dif") || "0") / 100).toString()); + } + history.pushState(null, document.title, "/payment"); + console.log(fromSquiz); + }, []); - async function handleChoosePaymentClick() { - if (Number(paymentValueField) !== 0) { - const [sendPaymentResponse, sendPaymentError] = await sendPayment({fromSquiz}) + useEffect(() => { + setPaymentLink(""); + }, [selectedPaymentMethod]); - if (sendPaymentError) { - return enqueueSnackbar(sendPaymentError) - } + async function handleChoosePaymentClick() { + if (Number(paymentValueField) === 0) { + return; + } - if (sendPaymentResponse) { - setPaymentLink(sendPaymentResponse.link) - } - } - } + if (selectedPaymentMethod !== "rspay") { + const [sendPaymentResponse, sendPaymentError] = await sendPayment({ + fromSquiz, + }); - const handleCustomBackNavigation = useHistoryTracker() + if (sendPaymentError) { + return enqueueSnackbar(sendPaymentError); + } - return ( - - - {!upMd && ( - - - - )} - Способ оплаты - - {!upMd && ( - + if (sendPaymentResponse) { + setPaymentLink(sendPaymentResponse.link); + } + + return; + } + + } + + const handleCustomBackNavigation = useHistoryTracker(); + + return ( + + + {!upMd && ( + + + + )} + Способ оплаты + + {!upMd && ( + Выберите способ оплаты - - )} - - - {paymentMethods.map((method) => ( - setSelectedPaymentMethod(method.name)} - /> - ))} - - - - {upMd && Выберите способ оплаты} - К оплате - {paymentLink ? ( - - {currencyFormatter.format(paymentValue / 100)} - - ) : ( - setPaymentValueField(e.target.value)} - id="payment-amount" - gap={upMd ? "16px" : "10px"} - color={"#F2F3F7"} - FormInputSx={{ mb: "28px" }} - /> - )} - - {paymentLink ? ( - - ) : ( - + ) : ( + - )} - - - - ) + + )} + + + + + ); } diff --git a/src/pages/Payment/PaymentMethodCard.tsx b/src/pages/Payment/PaymentMethodCard.tsx index 2ed39a1..42c53d9 100644 --- a/src/pages/Payment/PaymentMethodCard.tsx +++ b/src/pages/Payment/PaymentMethodCard.tsx @@ -1,43 +1,55 @@ -import { Button, Typography, useMediaQuery, useTheme } from "@mui/material" +import { Button, Typography, useMediaQuery, useTheme } from "@mui/material"; interface Props { - name: string; - image: string; - isSelected?: boolean; - onClick: () => void; + label: string; + image: string; + isSelected?: boolean; + unpopular?: boolean; + onClick: () => void; } -export default function PaymentMethodCard({ name, image, isSelected, onClick }: Props) { - const theme = useTheme() - const upSm = useMediaQuery(theme.breakpoints.up("sm")) +export default function PaymentMethodCard({ + label, + image, + isSelected, + unpopular, + onClick, +}: Props) { + const theme = useTheme(); + const upSm = useMediaQuery(theme.breakpoints.up("sm")); - return ( - - ) + return ( + + ); } diff --git a/src/pages/Payment/WarnModal.tsx b/src/pages/Payment/WarnModal.tsx new file mode 100644 index 0000000..493d4a8 --- /dev/null +++ b/src/pages/Payment/WarnModal.tsx @@ -0,0 +1,61 @@ +import { Modal, Box, Typography, Button, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +type WarnModalProps = { + open: boolean; + setOpen: (isOpen: boolean) => void; +}; + +export const WarnModal = ({ open, setOpen }: WarnModalProps) => { + const theme = useTheme(); + const navigate = useNavigate(); + + return ( + setOpen(false)} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + + + + Верификация не пройдена. + + + + + + + + + ); +}; diff --git a/src/pages/Support/SupportChat.tsx b/src/pages/Support/SupportChat.tsx index f51136e..d7a7e0e 100644 --- a/src/pages/Support/SupportChat.tsx +++ b/src/pages/Support/SupportChat.tsx @@ -1,327 +1,341 @@ import { - Box, - Button, - Fab, - FormControl, - IconButton, - InputAdornment, - InputBase, - Typography, - useMediaQuery, - useTheme, -} from "@mui/material" -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { useParams } from "react-router-dom" -import SendIcon from "@components/icons/SendIcon" -import { throttle, useToken } from "@frontend/kitui" -import { enqueueSnackbar } from "notistack" -import { useTicketStore } from "@root/stores/tickets" + Box, + Button, + Fab, + FormControl, + IconButton, + InputAdornment, + InputBase, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import SendIcon from "@components/icons/SendIcon"; +import { throttle, useToken } from "@frontend/kitui"; +import { enqueueSnackbar } from "notistack"; +import { useTicketStore } from "@root/stores/tickets"; import { - addOrUpdateMessages, - clearMessageState, - incrementMessageApiPage, - setIsPreventAutoscroll, - setTicketMessageFetchState, - useMessageStore, -} from "@root/stores/messages" -import { TicketMessage } from "@frontend/kitui" -import ChatMessage from "@root/components/ChatMessage" -import { cardShadow } from "@root/utils/theme" + addOrUpdateMessages, + clearMessageState, + incrementMessageApiPage, + setIsPreventAutoscroll, + setTicketMessageFetchState, + useMessageStore, +} from "@root/stores/messages"; +import { TicketMessage } from "@frontend/kitui"; +import ChatMessage from "@root/components/ChatMessage"; +import { cardShadow } from "@root/utils/theme"; import { - getMessageFromFetchError, - useEventListener, - useSSESubscription, - useTicketMessages, -} from "@frontend/kitui" -import { shownMessage, sendTicketMessage } from "@root/api/ticket" -import { withErrorBoundary } from "react-error-boundary" -import { handleComponentError } from "@root/utils/handleComponentError" + getMessageFromFetchError, + useEventListener, + useSSESubscription, + useTicketMessages, +} from "@frontend/kitui"; +import { shownMessage, sendTicketMessage } from "@root/api/ticket"; +import { withErrorBoundary } from "react-error-boundary"; +import { handleComponentError } from "@root/utils/handleComponentError"; +import { useSSETab } from "@root/utils/hooks/useSSETab"; function SupportChat() { - const theme = useTheme() - const upMd = useMediaQuery(theme.breakpoints.up("md")) - const isMobile = useMediaQuery(theme.breakpoints.up(460)) - const [messageField, setMessageField] = useState("") - const tickets = useTicketStore((state) => state.tickets) - const messages = useMessageStore((state) => state.messages) - const messageApiPage = useMessageStore((state) => state.apiPage) - const lastMessageId = useMessageStore((state) => state.lastMessageId) - const messagesPerPage = useMessageStore((state) => state.messagesPerPage) - const isPreventAutoscroll = useMessageStore( - (state) => state.isPreventAutoscroll - ) - const token = useToken() - const ticketId = useParams().ticketId - const ticket = tickets.find((ticket) => ticket.id === ticketId) - const chatBoxRef = useRef(null) - const fetchState = useMessageStore((state) => state.ticketMessageFetchState) + const theme = useTheme(); + const upMd = useMediaQuery(theme.breakpoints.up("md")); + const isMobile = useMediaQuery(theme.breakpoints.up(460)); + const [messageField, setMessageField] = useState(""); + const tickets = useTicketStore((state) => state.tickets); + const messages = useMessageStore((state) => state.messages); + const messageApiPage = useMessageStore((state) => state.apiPage); + const lastMessageId = useMessageStore((state) => state.lastMessageId); + const messagesPerPage = useMessageStore((state) => state.messagesPerPage); + const isPreventAutoscroll = useMessageStore( + (state) => state.isPreventAutoscroll + ); + const token = useToken(); + const ticketId = useParams().ticketId; + const ticket = tickets.find((ticket) => ticket.id === ticketId); + const chatBoxRef = useRef(null); + const fetchState = useMessageStore((state) => state.ticketMessageFetchState); + const { isActiveSSETab, updateSSEValue } = useSSETab( + "supportChat", + addOrUpdateMessages + ); - useTicketMessages({ - url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages", - ticketId, - messagesPerPage, - messageApiPage, - onSuccess: (messages) => { - if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) - chatBoxRef.current.scrollTop = 1 - addOrUpdateMessages(messages) - }, - onError: (error: Error) => { - const message = getMessageFromFetchError(error) - if (message) enqueueSnackbar(message) - }, - onFetchStateChange: setTicketMessageFetchState, - }) + useTicketMessages({ + url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages", + ticketId, + messagesPerPage, + messageApiPage, + onSuccess: (messages) => { + if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) + chatBoxRef.current.scrollTop = 1; + addOrUpdateMessages(messages); + }, + onError: (error: Error) => { + const message = getMessageFromFetchError(error); + if (message) enqueueSnackbar(message); + }, + onFetchStateChange: setTicketMessageFetchState, + }); - useSSESubscription({ - enabled: Boolean(token) && Boolean(ticketId), - url: process.env.REACT_APP_DOMAIN + `/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`, - onNewData: addOrUpdateMessages, - onDisconnect: useCallback(() => { - clearMessageState() - setIsPreventAutoscroll(false) - }, []), - marker: "ticket message", - }) + useSSESubscription({ + enabled: isActiveSSETab && Boolean(token) && Boolean(ticketId), + url: + process.env.REACT_APP_DOMAIN + + `/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`, + onNewData: (ticketMessages) => { + updateSSEValue(ticketMessages); + addOrUpdateMessages(ticketMessages); + }, + onDisconnect: useCallback(() => { + clearMessageState(); + setIsPreventAutoscroll(false); + }, []), + marker: "ticket message", + }); - const throttledScrollHandler = useMemo( - () => - throttle(() => { - const chatBox = chatBoxRef.current - if (!chatBox) return + const throttledScrollHandler = useMemo( + () => + throttle(() => { + const chatBox = chatBoxRef.current; + if (!chatBox) return; - const scrollBottom = - chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight - const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20 - setIsPreventAutoscroll(isPreventAutoscroll) + const scrollBottom = + chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight; + const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20; + setIsPreventAutoscroll(isPreventAutoscroll); - if (fetchState !== "idle") return + if (fetchState !== "idle") return; - if (chatBox.scrollTop < chatBox.clientHeight) { - incrementMessageApiPage() - } - }, 200), - [fetchState] - ) + if (chatBox.scrollTop < chatBox.clientHeight) { + incrementMessageApiPage(); + } + }, 200), + [fetchState] + ); - useEventListener("scroll", throttledScrollHandler, chatBoxRef) + useEventListener("scroll", throttledScrollHandler, chatBoxRef); - useEffect( - function scrollOnNewMessage() { - if (!chatBoxRef.current) return + useEffect( + function scrollOnNewMessage() { + if (!chatBoxRef.current) return; - if (!isPreventAutoscroll) { - setTimeout(() => { - scrollToBottom() - }, 50) - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [lastMessageId] - ) + if (!isPreventAutoscroll) { + setTimeout(() => { + scrollToBottom(); + }, 50); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [lastMessageId] + ); - useEffect(() => { - if (ticket) { - shownMessage(ticket.top_message.id) - } - }, [ticket]) + useEffect(() => { + if (ticket) { + shownMessage(ticket.top_message.id); + } + }, [ticket]); - async function handleSendMessage() { - if (!ticket || !messageField) return + async function handleSendMessage() { + if (!ticket || !messageField) return; - const [, sendTicketMessageError] = await sendTicketMessage( - ticket.id, - messageField - ) + const [, sendTicketMessageError] = await sendTicketMessage( + ticket.id, + messageField + ); - if (sendTicketMessageError) { - return enqueueSnackbar(sendTicketMessageError) - } + if (sendTicketMessageError) { + return enqueueSnackbar(sendTicketMessageError); + } - setMessageField("") - } + setMessageField(""); + } - function scrollToBottom(behavior?: ScrollBehavior) { - if (!chatBoxRef.current) return + function scrollToBottom(behavior?: ScrollBehavior) { + if (!chatBoxRef.current) return; - const chatBox = chatBoxRef.current - chatBox.scroll({ - left: 0, - top: chatBox.scrollHeight, - behavior, - }) - } + const chatBox = chatBoxRef.current; + chatBox.scroll({ + left: 0, + top: chatBox.scrollHeight, + behavior, + }); + } - const createdAt = ticket && new Date(ticket.created_at) - const createdAtString = + const createdAt = ticket && new Date(ticket.created_at); + const createdAtString = createdAt && createdAt.toLocaleDateString(undefined, { - year: "numeric", - month: "2-digit", - day: "2-digit", + year: "numeric", + month: "2-digit", + day: "2-digit", }) + " " + createdAt.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }) + hour: "2-digit", + minute: "2-digit", + }); - return ( - - - - {ticket?.title} - - + return ( + + + + {ticket?.title} + + Создан: {createdAtString} - - - - {isPreventAutoscroll && ( - scrollToBottom("smooth")} - sx={{ - position: "absolute", - left: "10px", - bottom: "10px", - }} - > - - - )} - - {ticket && + + + + {isPreventAutoscroll && ( + scrollToBottom("smooth")} + sx={{ + position: "absolute", + left: "10px", + bottom: "10px", + }} + > + + + )} + + {ticket && messages.map((message) => ( - + ))} - - - - setMessageField(e.target.value)} - endAdornment={ - !upMd && ( - - - - - - ) - } - /> - - - - {upMd && ( - - - - )} - - ) + + + )} + + ); } export default withErrorBoundary(SupportChat, { - fallback: Не удалось отобразить чат, - onError: handleComponentError, -}) + fallback: ( + + Не удалось отобразить чат + + ), + onError: handleComponentError, +}); diff --git a/src/utils/hooks/useSSETab.ts b/src/utils/hooks/useSSETab.ts new file mode 100644 index 0000000..69fa6e5 --- /dev/null +++ b/src/utils/hooks/useSSETab.ts @@ -0,0 +1,93 @@ +import { useState, useEffect, useRef } from "react"; + +type UseSSETabResult = { + isActiveSSETab: boolean; + updateSSEValue: (value: T) => void; +}; + +export const useSSETab = ( + sseName: string, + onUpdateValue?: (value: T) => void +): UseSSETabResult => { + const [openTimeSetted, seteOpenTimeSetted] = useState(false); + const [activeSSETab, setActiveSSETab] = useState(false); + const updateTimeIntervalId = useRef(null); + const checkConnectionIntervalId = useRef(null); + + useEffect(() => { + setOpenTime(); + checkConnectionIntervalId.current = setInterval(checkConnection, 5000); + + const onUpdate = (event: StorageEvent) => { + if (event.key === `sse-${sseName}-update` && event.newValue) { + onUpdateValue?.(JSON.parse(event.newValue)); + } + }; + + window.addEventListener("storage", onUpdate); + + return () => { + if (checkConnectionIntervalId.current) { + clearInterval(checkConnectionIntervalId.current); + } + + window.removeEventListener("storage", onUpdate); + }; + }, []); + + useEffect(() => { + if (activeSSETab) { + if (updateTimeIntervalId.current) { + clearInterval(updateTimeIntervalId.current); + } + + updateTime(); + updateTimeIntervalId.current = setInterval(updateTime, 5000); + + return () => { + setActiveSSETab(false); + + if (updateTimeIntervalId.current) { + clearInterval(updateTimeIntervalId.current); + } + }; + } + }, [activeSSETab]); + + const updateTime = () => { + const time = new Date().getTime(); + + localStorage.setItem(`sse-${sseName}`, String(time)); + }; + + const checkConnection = (): boolean => { + const time = new Date().getTime(); + const lastMessageTime = Number(localStorage.getItem(`sse-${sseName}`)); + + if (time - lastMessageTime > 15000) { + setActiveSSETab(true); + + return false; + } + + return true; + }; + + const setOpenTime = () => { + if (openTimeSetted) { + return; + } + + if (!checkConnection()) { + setActiveSSETab(true); + } + + seteOpenTimeSetted(true); + }; + + const updateSSEValue = (value: T) => { + localStorage.setItem(`sse-${sseName}-update`, JSON.stringify(value)); + }; + + return { isActiveSSETab: activeSSETab, updateSSEValue }; +};