Merge branch 'dev' into 'staging'

Dev

See merge request frontend/marketplace!125
This commit is contained in:
Nastya 2024-02-15 09:37:32 +00:00
commit e3c43782f7
9 changed files with 942 additions and 667 deletions

@ -1,8 +1,8 @@
import { makeRequest } from "@frontend/kitui" import { makeRequest } from "@frontend/kitui";
import { SendPaymentRequest, SendPaymentResponse } from "@root/model/wallet" import { SendPaymentRequest, SendPaymentResponse } from "@root/model/wallet";
import { parseAxiosError } from "@root/utils/parse-error" 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 = { const testPaymentBody: SendPaymentRequest = {
type: "bankCard", type: "bankCard",
@ -18,12 +18,16 @@ const testPaymentBody: SendPaymentRequest = {
phoneNumber: "79000000000", phoneNumber: "79000000000",
login: "login_test", login: "login_test",
returnUrl: window.location.origin + "/wallet", returnUrl: window.location.origin + "/wallet",
} };
export async function sendPayment( export async function sendPayment({
{body = testPaymentBody, fromSquiz = false}: {body?: SendPaymentRequest, fromSquiz:boolean} body = testPaymentBody,
): Promise<[SendPaymentResponse | null, string?]> { fromSquiz = false,
if (fromSquiz) body.returnUrl = "squiz.pena.digital/list?action=fromhub" }: {
body?: SendPaymentRequest;
fromSquiz: boolean;
}): Promise<[SendPaymentResponse | null, string?]> {
if (fromSquiz) body.returnUrl = "squiz.pena.digital/list?action=fromhub";
try { try {
const sendPaymentResponse = await makeRequest< const sendPaymentResponse = await makeRequest<
SendPaymentRequest, SendPaymentRequest,
@ -35,12 +39,29 @@ export async function sendPayment(
useToken: true, useToken: true,
withCredentials: false, withCredentials: false,
body, body,
}) });
return [sendPaymentResponse] return [sendPaymentResponse];
} catch (nativeError) { } catch (nativeError) {
const [error] = parseAxiosError(nativeError) const [error] = parseAxiosError(nativeError);
return [null, `Ошибка оплаты. ${error}`] return [null, `Ошибка оплаты. ${error}`];
} }
} }
export const sendRSPayment = async (): Promise<string | null> => {
try {
await makeRequest<never, string>({
url: apiUrl + "/wallet/rspay",
method: "POST",
useToken: true,
withCredentials: false,
});
return null;
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return `Ошибка оплаты. ${error}`;
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -34,6 +34,7 @@ import {
createTicket, createTicket,
} from "@frontend/kitui"; } from "@frontend/kitui";
import { sendTicketMessage, shownMessage } from "@root/api/ticket"; import { sendTicketMessage, shownMessage } from "@root/api/ticket";
import { useSSETab } from "@root/utils/hooks/useSSETab";
interface Props { interface Props {
open: boolean; open: boolean;
@ -61,6 +62,10 @@ export default function Chat({ open = false, sx }: Props) {
(state) => state.unauthTicketMessageFetchState (state) => state.unauthTicketMessageFetchState
); );
const chatBoxRef = useRef<HTMLDivElement>(null); const chatBoxRef = useRef<HTMLDivElement>(null);
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"ticket",
addOrUpdateUnauthMessages
);
useTicketMessages({ useTicketMessages({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages", url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages",
@ -81,11 +86,14 @@ export default function Chat({ open = false, sx }: Props) {
}); });
useSSESubscription<TicketMessage>({ useSSESubscription<TicketMessage>({
enabled: Boolean(sessionData), enabled: isActiveSSETab && Boolean(sessionData),
url: url:
process.env.REACT_APP_DOMAIN + process.env.REACT_APP_DOMAIN +
`/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`, `/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: addOrUpdateUnauthMessages, onNewData: (ticketMessages) => {
updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages);
},
onDisconnect: useCallback(() => { onDisconnect: useCallback(() => {
setUnauthIsPreventAutoscroll(false); setUnauthIsPreventAutoscroll(false);
}, []), }, []),

@ -1,5 +1,5 @@
import { Outlet } from "react-router-dom" import { Outlet } from "react-router-dom";
import Navbar from "./NavbarSite/Navbar" import Navbar from "./NavbarSite/Navbar";
import { import {
Ticket, Ticket,
getMessageFromFetchError, getMessageFromFetchError,
@ -8,75 +8,91 @@ import {
useSSESubscription, useSSESubscription,
useTicketsFetcher, useTicketsFetcher,
useToken, useToken,
} from "@frontend/kitui" } from "@frontend/kitui";
import { updateTickets, setTicketCount, useTicketStore, setTicketsFetchState } from "@root/stores/tickets" import {
import { enqueueSnackbar } from "notistack" updateTickets,
import { updateTariffs } from "@root/stores/tariffs" setTicketCount,
import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs" useTicketStore,
import { setCustomTariffs } from "@root/stores/customTariffs" setTicketsFetchState,
import { useDiscounts } from "@root/utils/hooks/useDiscounts" } from "@root/stores/tickets";
import { setDiscounts } from "@root/stores/discounts" import { enqueueSnackbar } from "notistack";
import { setPrivileges } from "@root/stores/privileges" 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 { useHistoryData } from "@root/utils/hooks/useHistoryData";
import { useSSETab } from "@root/utils/hooks/useSSETab";
export default function ProtectedLayout() { export default function ProtectedLayout() {
const token = useToken() const token = useToken();
const ticketApiPage = useTicketStore((state) => state.apiPage) const ticketApiPage = useTicketStore((state) => state.apiPage);
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage) const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
const { isActiveSSETab, updateSSEValue } = useSSETab<Ticket[]>(
"auth",
(data) => {
updateTickets(data.filter((d) => Boolean(d.id)));
setTicketCount(data.length);
}
);
useSSESubscription<Ticket>({ useSSESubscription<Ticket>({
url: process.env.REACT_APP_DOMAIN + `/heruvym/subscribe?Authorization=${token}`, enabled: isActiveSSETab,
url:
process.env.REACT_APP_DOMAIN +
`/heruvym/subscribe?Authorization=${token}`,
onNewData: (data) => { onNewData: (data) => {
updateTickets(data.filter((d) => Boolean(d.id))) updateSSEValue(data);
setTicketCount(data.length) updateTickets(data.filter((d) => Boolean(d.id)));
setTicketCount(data.length);
}, },
marker: "ticket", marker: "ticket",
}) });
useTicketsFetcher({ useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets", url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage, ticketsPerPage,
ticketApiPage, ticketApiPage,
onSuccess: (result) => { onSuccess: (result) => {
if (result.data) updateTickets(result.data) if (result.data) updateTickets(result.data);
setTicketCount(result.count) setTicketCount(result.count);
}, },
onError: (error: Error) => { onError: (error: Error) => {
const message = getMessageFromFetchError(error) const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message) if (message) enqueueSnackbar(message);
}, },
onFetchStateChange: setTicketsFetchState, onFetchStateChange: setTicketsFetchState,
}) });
useAllTariffsFetcher({ useAllTariffsFetcher({
onSuccess: updateTariffs, onSuccess: updateTariffs,
onError: (error) => { onError: (error) => {
const errorMessage = getMessageFromFetchError(error) const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage) if (errorMessage) enqueueSnackbar(errorMessage);
}, },
}) });
useCustomTariffs({ useCustomTariffs({
onNewUser: setCustomTariffs, onNewUser: setCustomTariffs,
onError: (error) => { onError: (error) => {
if (error) enqueueSnackbar(error) if (error) enqueueSnackbar(error);
}, },
}) });
useDiscounts({ useDiscounts({
onNewDiscounts: setDiscounts, onNewDiscounts: setDiscounts,
onError: (error) => { onError: (error) => {
const message = getMessageFromFetchError(error) const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message) if (message) enqueueSnackbar(message);
}, },
}) });
usePrivilegeFetcher({ usePrivilegeFetcher({
onSuccess: setPrivileges, onSuccess: setPrivileges,
onError: (error) => { onError: (error) => {
console.log("usePrivilegeFetcher error :>> ", error) console.log("usePrivilegeFetcher error :>> ", error);
}, },
}) });
useHistoryData(); useHistoryData();
@ -84,5 +100,5 @@ export default function ProtectedLayout() {
<Navbar> <Navbar>
<Outlet /> <Outlet />
</Navbar> </Navbar>
) );
} }

@ -5,85 +5,107 @@ import {
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material" } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack" import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SectionWrapper from "@components/SectionWrapper" import SectionWrapper from "@components/SectionWrapper";
import PaymentMethodCard from "./PaymentMethodCard" import PaymentMethodCard from "./PaymentMethodCard";
import mastercardLogo from "../../assets/bank-logo/logo-mastercard.png" import mastercardLogo from "@root/assets/bank-logo/logo-mastercard.png";
import visaLogo from "../../assets/bank-logo/logo-visa.png" import visaLogo from "@root/assets/bank-logo/logo-visa.png";
import qiwiLogo from "../../assets/bank-logo/logo-qiwi.png" import qiwiLogo from "@root/assets/bank-logo/logo-qiwi.png";
import mirLogo from "../../assets/bank-logo/logo-mir.png" import mirLogo from "@root/assets/bank-logo/logo-mir.png";
import tinkoffLogo from "../../assets/bank-logo/logo-tinkoff.png" import tinkoffLogo from "@root/assets/bank-logo/logo-tinkoff.png";
import { cardShadow } from "@root/utils/theme" import rsPayLogo from "@root/assets/bank-logo/rs-pay.png";
import { useEffect, useLayoutEffect, useState } from "react" import { cardShadow } from "@root/utils/theme";
import InputTextfield from "@root/components/InputTextfield" import { useEffect, useLayoutEffect, useState } from "react";
import { sendPayment } from "@root/api/wallet" import InputTextfield from "@root/components/InputTextfield";
import { getMessageFromFetchError } from "@frontend/kitui" import { sendPayment, sendRSPayment } from "@root/api/wallet";
import { enqueueSnackbar } from "notistack" import { getMessageFromFetchError } from "@frontend/kitui";
import { currencyFormatter } from "@root/utils/currencyFormatter" import { enqueueSnackbar } from "notistack";
import { useLocation, useNavigate } from "react-router-dom" import { currencyFormatter } from "@root/utils/currencyFormatter";
import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker" 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 = [ type PaymentMethod = {
{ name: "Mastercard", image: mastercardLogo }, label: string;
{ name: "Visa", image: visaLogo }, name: string;
{ name: "QIWI Кошелек", image: qiwiLogo }, image: string;
{ name: "Мир", image: mirLogo }, unpopular?: boolean;
{ name: "Тинькофф", image: tinkoffLogo }, };
] as const
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() { export default function Payment() {
const theme = useTheme() const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")) const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm")) const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)) const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [selectedPaymentMethod, setSelectedPaymentMethod] = const [selectedPaymentMethod, setSelectedPaymentMethod] =
useState<PaymentMethod | null>(null) useState<PaymentMethodType | null>(null);
const [paymentValueField, setPaymentValueField] = useState<string>("0") const [warnModalOpen, setWarnModalOpen] = useState<boolean>(false);
const [paymentLink, setPaymentLink] = useState<string>("") const [paymentValueField, setPaymentValueField] = useState<string>("0");
const [fromSquiz, setIsFromSquiz] = useState<boolean>(false) const [paymentLink, setPaymentLink] = useState<string>("");
const location = useLocation() const [fromSquiz, setIsFromSquiz] = useState<boolean>(false);
const location = useLocation();
const verificationStatus = useUserStore((state) => state.verificationStatus);
const navigate = useNavigate();
const notEnoughMoneyAmount = const notEnoughMoneyAmount =
(location.state?.notEnoughMoneyAmount as number) ?? 0 (location.state?.notEnoughMoneyAmount as number) ?? 0;
const paymentValue = parseFloat(paymentValueField) * 100 const paymentValue = parseFloat(paymentValueField) * 100;
useLayoutEffect(() => { useLayoutEffect(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps setPaymentValueField((notEnoughMoneyAmount / 100).toString());
setPaymentValueField((notEnoughMoneyAmount / 100).toString()) const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(window.location.search) const fromSquiz = params.get("action");
const fromSquiz = params.get("action")
if (fromSquiz === "squizpay") { if (fromSquiz === "squizpay") {
setIsFromSquiz(true) setIsFromSquiz(true);
setPaymentValueField((Number(params.get("dif") || "0") / 100).toString()) setPaymentValueField((Number(params.get("dif") || "0") / 100).toString());
} }
history.pushState(null, document.title, "/payment"); history.pushState(null, document.title, "/payment");
console.log(fromSquiz) console.log(fromSquiz);
}, []) }, []);
useEffect(() => { useEffect(() => {
setPaymentLink("") setPaymentLink("");
}, [selectedPaymentMethod]) }, [selectedPaymentMethod]);
async function handleChoosePaymentClick() { async function handleChoosePaymentClick() {
if (Number(paymentValueField) !== 0) { if (Number(paymentValueField) === 0) {
const [sendPaymentResponse, sendPaymentError] = await sendPayment({fromSquiz}) return;
}
if (selectedPaymentMethod !== "rspay") {
const [sendPaymentResponse, sendPaymentError] = await sendPayment({
fromSquiz,
});
if (sendPaymentError) { if (sendPaymentError) {
return enqueueSnackbar(sendPaymentError) return enqueueSnackbar(sendPaymentError);
} }
if (sendPaymentResponse) { if (sendPaymentResponse) {
setPaymentLink(sendPaymentResponse.link) setPaymentLink(sendPaymentResponse.link);
}
}
} }
const handleCustomBackNavigation = useHistoryTracker() return;
}
}
const handleCustomBackNavigation = useHistoryTracker();
return ( return (
<SectionWrapper <SectionWrapper
@ -137,15 +159,42 @@ export default function Payment() {
alignContent: "start", alignContent: "start",
}} }}
> >
{paymentMethods.map((method) => ( {paymentMethods.map(({ name, label, image, unpopular = false }) => (
<PaymentMethodCard <PaymentMethodCard
isSelected={selectedPaymentMethod === method.name} isSelected={selectedPaymentMethod === name}
key={method.name} key={name}
name={method.name} label={label}
image={method.image} image={image}
onClick={() => setSelectedPaymentMethod(method.name)} onClick={() => setSelectedPaymentMethod(name)}
unpopular={unpopular}
/> />
))} ))}
<PaymentMethodCard
isSelected={false}
label={"Расчётный счёт"}
image={rsPayLogo}
onClick={async() => {
if (verificationStatus !== VerificationStatus.VERIFICATED) {
setWarnModalOpen(true);
return;
}
const sendRSPaymentError = await sendRSPayment();
if (sendRSPaymentError) {
return enqueueSnackbar(sendRSPaymentError);
}
enqueueSnackbar(
"Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
);
navigate("/settings");
}}
unpopular={true}
/>
</Box> </Box>
<Box <Box
sx={{ sx={{
@ -237,6 +286,7 @@ export default function Payment() {
)} )}
</Box> </Box>
</Box> </Box>
<WarnModal open={warnModalOpen} setOpen={setWarnModalOpen} />
</SectionWrapper> </SectionWrapper>
) );
} }

@ -1,15 +1,22 @@
import { Button, Typography, useMediaQuery, useTheme } from "@mui/material" import { Button, Typography, useMediaQuery, useTheme } from "@mui/material";
interface Props { interface Props {
name: string; label: string;
image: string; image: string;
isSelected?: boolean; isSelected?: boolean;
unpopular?: boolean;
onClick: () => void; onClick: () => void;
} }
export default function PaymentMethodCard({ name, image, isSelected, onClick }: Props) { export default function PaymentMethodCard({
const theme = useTheme() label,
const upSm = useMediaQuery(theme.breakpoints.up("sm")) image,
isSelected,
unpopular,
onClick,
}: Props) {
const theme = useTheme();
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
return ( return (
<Button <Button
@ -20,24 +27,29 @@ export default function PaymentMethodCard({ name, image, isSelected, onClick }:
display: "flex", display: "flex",
justifyContent: "start", justifyContent: "start",
borderRadius: "8px", borderRadius: "8px",
filter: unpopular ? "saturate(0.6) brightness(0.85)" : null,
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
border: isSelected ? `1px solid ${theme.palette.purple.main}` : `1px solid ${theme.palette.gray.main}`, border: isSelected
gap: "20px", ? `1px solid ${theme.palette.purple.main}`
: `1px solid ${theme.palette.gray.main}`,
gap: "15px",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: "wrap",
boxShadow: isSelected ? `0 0 0 1.5px ${theme.palette.purple.main};` : "none", boxShadow: isSelected
? `0 0 0 1.5px ${theme.palette.purple.main};`
: "none",
"&:hover": { "&:hover": {
backgroundColor: theme.palette.purple.main, backgroundColor: theme.palette.purple.main,
border: `1px solid ${theme.palette.purple.main}`, border: `1px solid ${theme.palette.purple.main}`,
"& > p": { "& > p": {
color: "white", color: "white",
} },
}, },
}} }}
onClick={onClick} onClick={onClick}
> >
<img src={image} alt="payment method" /> <img src={image} alt="payment method" />
<Typography sx={{ color: theme.palette.gray.dark }}>{name}</Typography> <Typography sx={{ color: theme.palette.gray.dark }}>{label}</Typography>
</Button> </Button>
) );
} }

@ -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 (
<Modal
open={open}
onClose={() => setOpen(false)}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
sx={{
margin: "10px",
padding: "25px",
maxWidth: "600px",
borderRadius: "5px",
textAlign: "center",
background: theme.palette.background.default,
}}
>
<Box>
<Typography id="modal-modal-title" variant="h6" component="h2">
Верификация не пройдена.
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexWrap: "wrap",
gap: "20px",
marginTop: "15px",
}}
>
<Button variant="pena-outlined-purple" onClick={() => setOpen(false)}>
Отмена
</Button>
<Button
variant="pena-outlined-purple"
onClick={() => navigate("/settings")}
>
Пройти верификацию
</Button>
</Box>
</Box>
</Modal>
);
};

@ -9,14 +9,14 @@ import {
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material" } from "@mui/material";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward" import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom";
import SendIcon from "@components/icons/SendIcon" import SendIcon from "@components/icons/SendIcon";
import { throttle, useToken } from "@frontend/kitui" import { throttle, useToken } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack" import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets" import { useTicketStore } from "@root/stores/tickets";
import { import {
addOrUpdateMessages, addOrUpdateMessages,
clearMessageState, clearMessageState,
@ -24,38 +24,43 @@ import {
setIsPreventAutoscroll, setIsPreventAutoscroll,
setTicketMessageFetchState, setTicketMessageFetchState,
useMessageStore, useMessageStore,
} from "@root/stores/messages" } from "@root/stores/messages";
import { TicketMessage } from "@frontend/kitui" import { TicketMessage } from "@frontend/kitui";
import ChatMessage from "@root/components/ChatMessage" import ChatMessage from "@root/components/ChatMessage";
import { cardShadow } from "@root/utils/theme" import { cardShadow } from "@root/utils/theme";
import { import {
getMessageFromFetchError, getMessageFromFetchError,
useEventListener, useEventListener,
useSSESubscription, useSSESubscription,
useTicketMessages, useTicketMessages,
} from "@frontend/kitui" } from "@frontend/kitui";
import { shownMessage, sendTicketMessage } from "@root/api/ticket" import { shownMessage, sendTicketMessage } from "@root/api/ticket";
import { withErrorBoundary } from "react-error-boundary" import { withErrorBoundary } from "react-error-boundary";
import { handleComponentError } from "@root/utils/handleComponentError" import { handleComponentError } from "@root/utils/handleComponentError";
import { useSSETab } from "@root/utils/hooks/useSSETab";
function SupportChat() { function SupportChat() {
const theme = useTheme() const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")) const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.up(460)) const isMobile = useMediaQuery(theme.breakpoints.up(460));
const [messageField, setMessageField] = useState<string>("") const [messageField, setMessageField] = useState<string>("");
const tickets = useTicketStore((state) => state.tickets) const tickets = useTicketStore((state) => state.tickets);
const messages = useMessageStore((state) => state.messages) const messages = useMessageStore((state) => state.messages);
const messageApiPage = useMessageStore((state) => state.apiPage) const messageApiPage = useMessageStore((state) => state.apiPage);
const lastMessageId = useMessageStore((state) => state.lastMessageId) const lastMessageId = useMessageStore((state) => state.lastMessageId);
const messagesPerPage = useMessageStore((state) => state.messagesPerPage) const messagesPerPage = useMessageStore((state) => state.messagesPerPage);
const isPreventAutoscroll = useMessageStore( const isPreventAutoscroll = useMessageStore(
(state) => state.isPreventAutoscroll (state) => state.isPreventAutoscroll
) );
const token = useToken() const token = useToken();
const ticketId = useParams().ticketId const ticketId = useParams().ticketId;
const ticket = tickets.find((ticket) => ticket.id === ticketId) const ticket = tickets.find((ticket) => ticket.id === ticketId);
const chatBoxRef = useRef<HTMLDivElement>(null) const chatBoxRef = useRef<HTMLDivElement>(null);
const fetchState = useMessageStore((state) => state.ticketMessageFetchState) const fetchState = useMessageStore((state) => state.ticketMessageFetchState);
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"supportChat",
addOrUpdateMessages
);
useTicketMessages({ useTicketMessages({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages", url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages",
@ -64,96 +69,101 @@ function SupportChat() {
messageApiPage, messageApiPage,
onSuccess: (messages) => { onSuccess: (messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1)
chatBoxRef.current.scrollTop = 1 chatBoxRef.current.scrollTop = 1;
addOrUpdateMessages(messages) addOrUpdateMessages(messages);
}, },
onError: (error: Error) => { onError: (error: Error) => {
const message = getMessageFromFetchError(error) const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message) if (message) enqueueSnackbar(message);
}, },
onFetchStateChange: setTicketMessageFetchState, onFetchStateChange: setTicketMessageFetchState,
}) });
useSSESubscription<TicketMessage>({ useSSESubscription<TicketMessage>({
enabled: Boolean(token) && Boolean(ticketId), enabled: isActiveSSETab && Boolean(token) && Boolean(ticketId),
url: process.env.REACT_APP_DOMAIN + `/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`, url:
onNewData: addOrUpdateMessages, process.env.REACT_APP_DOMAIN +
`/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
onNewData: (ticketMessages) => {
updateSSEValue(ticketMessages);
addOrUpdateMessages(ticketMessages);
},
onDisconnect: useCallback(() => { onDisconnect: useCallback(() => {
clearMessageState() clearMessageState();
setIsPreventAutoscroll(false) setIsPreventAutoscroll(false);
}, []), }, []),
marker: "ticket message", marker: "ticket message",
}) });
const throttledScrollHandler = useMemo( const throttledScrollHandler = useMemo(
() => () =>
throttle(() => { throttle(() => {
const chatBox = chatBoxRef.current const chatBox = chatBoxRef.current;
if (!chatBox) return if (!chatBox) return;
const scrollBottom = const scrollBottom =
chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight chatBox.scrollHeight - chatBox.scrollTop - chatBox.clientHeight;
const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20 const isPreventAutoscroll = scrollBottom > chatBox.clientHeight * 20;
setIsPreventAutoscroll(isPreventAutoscroll) setIsPreventAutoscroll(isPreventAutoscroll);
if (fetchState !== "idle") return if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) { if (chatBox.scrollTop < chatBox.clientHeight) {
incrementMessageApiPage() incrementMessageApiPage();
} }
}, 200), }, 200),
[fetchState] [fetchState]
) );
useEventListener("scroll", throttledScrollHandler, chatBoxRef) useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect( useEffect(
function scrollOnNewMessage() { function scrollOnNewMessage() {
if (!chatBoxRef.current) return if (!chatBoxRef.current) return;
if (!isPreventAutoscroll) { if (!isPreventAutoscroll) {
setTimeout(() => { setTimeout(() => {
scrollToBottom() scrollToBottom();
}, 50) }, 50);
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[lastMessageId] [lastMessageId]
) );
useEffect(() => { useEffect(() => {
if (ticket) { if (ticket) {
shownMessage(ticket.top_message.id) shownMessage(ticket.top_message.id);
} }
}, [ticket]) }, [ticket]);
async function handleSendMessage() { async function handleSendMessage() {
if (!ticket || !messageField) return if (!ticket || !messageField) return;
const [, sendTicketMessageError] = await sendTicketMessage( const [, sendTicketMessageError] = await sendTicketMessage(
ticket.id, ticket.id,
messageField messageField
) );
if (sendTicketMessageError) { if (sendTicketMessageError) {
return enqueueSnackbar(sendTicketMessageError) return enqueueSnackbar(sendTicketMessageError);
} }
setMessageField("") setMessageField("");
} }
function scrollToBottom(behavior?: ScrollBehavior) { function scrollToBottom(behavior?: ScrollBehavior) {
if (!chatBoxRef.current) return if (!chatBoxRef.current) return;
const chatBox = chatBoxRef.current const chatBox = chatBoxRef.current;
chatBox.scroll({ chatBox.scroll({
left: 0, left: 0,
top: chatBox.scrollHeight, top: chatBox.scrollHeight,
behavior, behavior,
}) });
} }
const createdAt = ticket && new Date(ticket.created_at) const createdAt = ticket && new Date(ticket.created_at);
const createdAtString = const createdAtString =
createdAt && createdAt &&
createdAt.toLocaleDateString(undefined, { createdAt.toLocaleDateString(undefined, {
@ -165,7 +175,7 @@ function SupportChat() {
createdAt.toLocaleTimeString(undefined, { createdAt.toLocaleTimeString(undefined, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}) });
return ( return (
<Box <Box
@ -318,10 +328,14 @@ function SupportChat() {
</Box> </Box>
)} )}
</Box> </Box>
) );
} }
export default withErrorBoundary(SupportChat, { export default withErrorBoundary(SupportChat, {
fallback: <Typography mt="8px" textAlign="center">Не удалось отобразить чат</Typography>, fallback: (
<Typography mt="8px" textAlign="center">
Не удалось отобразить чат
</Typography>
),
onError: handleComponentError, onError: handleComponentError,
}) });

@ -0,0 +1,93 @@
import { useState, useEffect, useRef } from "react";
type UseSSETabResult = {
isActiveSSETab: boolean;
updateSSEValue: <T>(value: T) => void;
};
export const useSSETab = <T = unknown>(
sseName: string,
onUpdateValue?: (value: T) => void
): UseSSETabResult => {
const [openTimeSetted, seteOpenTimeSetted] = useState<boolean>(false);
const [activeSSETab, setActiveSSETab] = useState<boolean>(false);
const updateTimeIntervalId = useRef<NodeJS.Timer | null>(null);
const checkConnectionIntervalId = useRef<NodeJS.Timer | null>(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 = <T>(value: T) => {
localStorage.setItem(`sse-${sseName}-update`, JSON.stringify(value));
};
return { isActiveSSETab: activeSSETab, updateSSEValue };
};