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,46 +1,67 @@
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",
amount: 15020, amount: 15020,
currency: "RUB", currency: "RUB",
bankCard: { bankCard: {
number: "RUB", number: "RUB",
expiryYear: "2021", expiryYear: "2021",
expiryMonth: "05", expiryMonth: "05",
csc: "05", csc: "05",
cardholder: "IVAN IVANOV", cardholder: "IVAN IVANOV",
}, },
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" }: {
try { body?: SendPaymentRequest;
const sendPaymentResponse = await makeRequest< fromSquiz: boolean;
}): Promise<[SendPaymentResponse | null, string?]> {
if (fromSquiz) body.returnUrl = "squiz.pena.digital/list?action=fromhub";
try {
const sendPaymentResponse = await makeRequest<
SendPaymentRequest, SendPaymentRequest,
SendPaymentResponse SendPaymentResponse
>({ >({
url: apiUrl + "/wallet", url: apiUrl + "/wallet",
contentType: true, contentType: true,
method: "POST", method: "POST",
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,88 +1,104 @@
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,
useAllTariffsFetcher, useAllTariffsFetcher,
usePrivilegeFetcher, usePrivilegeFetcher,
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>({
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<Ticket>({ useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + `/heruvym/subscribe?Authorization=${token}`, url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
onNewData: (data) => { ticketsPerPage,
updateTickets(data.filter((d) => Boolean(d.id))) ticketApiPage,
setTicketCount(data.length) onSuccess: (result) => {
}, if (result.data) updateTickets(result.data);
marker: "ticket", setTicketCount(result.count);
}) },
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: setTicketsFetchState,
});
useTicketsFetcher({ useAllTariffsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets", onSuccess: updateTariffs,
ticketsPerPage, onError: (error) => {
ticketApiPage, const errorMessage = getMessageFromFetchError(error);
onSuccess: (result) => { if (errorMessage) enqueueSnackbar(errorMessage);
if (result.data) updateTickets(result.data) },
setTicketCount(result.count) });
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error)
if (message) enqueueSnackbar(message)
},
onFetchStateChange: setTicketsFetchState,
})
useAllTariffsFetcher({ useCustomTariffs({
onSuccess: updateTariffs, onNewUser: setCustomTariffs,
onError: (error) => { onError: (error) => {
const errorMessage = getMessageFromFetchError(error) if (error) enqueueSnackbar(error);
if (errorMessage) enqueueSnackbar(errorMessage) },
}, });
})
useCustomTariffs({ useDiscounts({
onNewUser: setCustomTariffs, onNewDiscounts: setDiscounts,
onError: (error) => { onError: (error) => {
if (error) enqueueSnackbar(error) const message = getMessageFromFetchError(error);
}, if (message) enqueueSnackbar(message);
}) },
});
useDiscounts({ usePrivilegeFetcher({
onNewDiscounts: setDiscounts, onSuccess: setPrivileges,
onError: (error) => { onError: (error) => {
const message = getMessageFromFetchError(error) console.log("usePrivilegeFetcher error :>> ", error);
if (message) enqueueSnackbar(message) },
}, });
})
usePrivilegeFetcher({ useHistoryData();
onSuccess: setPrivileges,
onError: (error) => {
console.log("usePrivilegeFetcher error :>> ", error)
},
})
useHistoryData(); return (
<Navbar>
return ( <Outlet />
<Navbar> </Navbar>
<Outlet /> );
</Navbar>
)
} }

@ -1,242 +1,292 @@
import { import {
Box, Box,
Button, Button,
IconButton, IconButton,
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 (sendPaymentError) { if (selectedPaymentMethod !== "rspay") {
return enqueueSnackbar(sendPaymentError) const [sendPaymentResponse, sendPaymentError] = await sendPayment({
} fromSquiz,
});
if (sendPaymentResponse) { if (sendPaymentError) {
setPaymentLink(sendPaymentResponse.link) return enqueueSnackbar(sendPaymentError);
} }
}
}
const handleCustomBackNavigation = useHistoryTracker() if (sendPaymentResponse) {
setPaymentLink(sendPaymentResponse.link);
}
return ( return;
<SectionWrapper }
maxWidth="lg"
sx={{ }
mt: "25px",
mb: "70px", const handleCustomBackNavigation = useHistoryTracker();
px: isTablet ? (upMd ? "40px" : "18px") : "20px",
}} return (
> <SectionWrapper
<Box maxWidth="lg"
sx={{ sx={{
mt: "20px", mt: "25px",
mb: "40px", mb: "70px",
display: "flex", px: isTablet ? (upMd ? "40px" : "18px") : "20px",
gap: "10px", }}
}} >
> <Box
{!upMd && ( sx={{
<IconButton mt: "20px",
onClick={handleCustomBackNavigation} mb: "40px",
sx={{ p: 0, height: "28px", width: "28px", color: "black" }} display: "flex",
> gap: "10px",
<ArrowBackIcon /> }}
</IconButton> >
)} {!upMd && (
<Typography variant="h4">Способ оплаты</Typography> <IconButton
</Box> onClick={handleCustomBackNavigation}
{!upMd && ( sx={{ p: 0, height: "28px", width: "28px", color: "black" }}
<Typography variant="body2" mb="30px"> >
<ArrowBackIcon />
</IconButton>
)}
<Typography variant="h4">Способ оплаты</Typography>
</Box>
{!upMd && (
<Typography variant="body2" mb="30px">
Выберите способ оплаты Выберите способ оплаты
</Typography> </Typography>
)} )}
<Box <Box
sx={{ sx={{
backgroundColor: upMd ? "white" : undefined, backgroundColor: upMd ? "white" : undefined,
display: "flex", display: "flex",
flexDirection: upMd ? "row" : "column", flexDirection: upMd ? "row" : "column",
borderRadius: "12px", borderRadius: "12px",
boxShadow: upMd ? cardShadow : undefined, boxShadow: upMd ? cardShadow : undefined,
}} }}
> >
<Box <Box
sx={{ sx={{
width: upMd ? "68.5%" : undefined, width: upMd ? "68.5%" : undefined,
p: upMd ? "20px" : undefined, p: upMd ? "20px" : undefined,
display: "flex", display: "flex",
flexDirection: upSm ? "row" : "column", flexDirection: upSm ? "row" : "column",
flexWrap: "wrap", flexWrap: "wrap",
gap: upMd ? "14px" : "20px", gap: upMd ? "14px" : "20px",
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}
))} />
</Box> ))}
<Box <PaymentMethodCard
sx={{ isSelected={false}
display: "flex", label={"Расчётный счёт"}
flexDirection: "column", image={rsPayLogo}
justifyContent: "space-between", onClick={async() => {
alignItems: "start",
color: theme.palette.gray.dark, if (verificationStatus !== VerificationStatus.VERIFICATED) {
width: upMd ? "31.5%" : undefined, setWarnModalOpen(true);
p: upMd ? "20px" : undefined,
pl: upMd ? "33px" : undefined, return;
mt: upMd ? undefined : "30px", }
borderLeft: upMd
? `1px solid ${theme.palette.gray.main}` const sendRSPaymentError = await sendRSPayment();
: undefined,
}} if (sendRSPaymentError) {
> return enqueueSnackbar(sendRSPaymentError);
<Box }
sx={{
display: "flex", enqueueSnackbar(
flexDirection: "column", "Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
maxWidth: "85%", );
}}
> navigate("/settings");
{upMd && <Typography mb="56px">Выберите способ оплаты</Typography>} }}
<Typography mb="20px">К оплате</Typography> unpopular={true}
{paymentLink ? ( />
<Typography </Box>
sx={{ <Box
fontWeight: 500, sx={{
fontSize: "20px", display: "flex",
lineHeight: "48px", flexDirection: "column",
mb: "28px", justifyContent: "space-between",
}} alignItems: "start",
> color: theme.palette.gray.dark,
{currencyFormatter.format(paymentValue / 100)} width: upMd ? "31.5%" : undefined,
</Typography> p: upMd ? "20px" : undefined,
) : ( pl: upMd ? "33px" : undefined,
<InputTextfield mt: upMd ? undefined : "30px",
TextfieldProps={{ borderLeft: upMd
placeholder: "К оплате", ? `1px solid ${theme.palette.gray.main}`
value: paymentValueField, : undefined,
type: "number", }}
}} >
onChange={(e) => setPaymentValueField(e.target.value)} <Box
id="payment-amount" sx={{
gap={upMd ? "16px" : "10px"} display: "flex",
color={"#F2F3F7"} flexDirection: "column",
FormInputSx={{ mb: "28px" }} maxWidth: "85%",
/> }}
)} >
</Box> {upMd && <Typography mb="56px">Выберите способ оплаты</Typography>}
{paymentLink ? ( <Typography mb="20px">К оплате</Typography>
<Button {paymentLink ? (
variant="pena-outlined-light" <Typography
component="a" sx={{
href={paymentLink} fontWeight: 500,
sx={{ fontSize: "20px",
mt: "auto", lineHeight: "48px",
color: "black", mb: "28px",
border: `1px solid ${theme.palette.purple.main}`, }}
"&:hover": { >
backgroundColor: theme.palette.purple.dark, {currencyFormatter.format(paymentValue / 100)}
border: `1px solid ${theme.palette.purple.dark}`, </Typography>
}, ) : (
}} <InputTextfield
> TextfieldProps={{
placeholder: "К оплате",
value: paymentValueField,
type: "number",
}}
onChange={(e) => setPaymentValueField(e.target.value)}
id="payment-amount"
gap={upMd ? "16px" : "10px"}
color={"#F2F3F7"}
FormInputSx={{ mb: "28px" }}
/>
)}
</Box>
{paymentLink ? (
<Button
variant="pena-outlined-light"
component="a"
href={paymentLink}
sx={{
mt: "auto",
color: "black",
border: `1px solid ${theme.palette.purple.main}`,
"&:hover": {
backgroundColor: theme.palette.purple.dark,
border: `1px solid ${theme.palette.purple.dark}`,
},
}}
>
Оплатить Оплатить
</Button> </Button>
) : ( ) : (
<Button <Button
variant="pena-outlined-light" variant="pena-outlined-light"
disabled={!isFinite(paymentValue)} disabled={!isFinite(paymentValue)}
onClick={handleChoosePaymentClick} onClick={handleChoosePaymentClick}
sx={{ sx={{
mt: "auto", mt: "auto",
color: "black", color: "black",
border: `1px solid ${theme.palette.purple.main}`, border: `1px solid ${theme.palette.purple.main}`,
"&:hover": { "&:hover": {
color: "white", color: "white",
}, },
"&:active": { "&:active": {
color: "white", color: "white",
}, },
}} }}
> >
Выбрать Выбрать
</Button> </Button>
)} )}
</Box> </Box>
</Box> </Box>
</SectionWrapper> <WarnModal open={warnModalOpen} setOpen={setWarnModalOpen} />
) </SectionWrapper>
);
} }

@ -1,43 +1,55 @@
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;
onClick: () => void; unpopular?: boolean;
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
sx={{ sx={{
width: upSm ? "237px" : "100%", width: upSm ? "237px" : "100%",
p: "20px", p: "20px",
pr: "10px", pr: "10px",
display: "flex", display: "flex",
justifyContent: "start", justifyContent: "start",
borderRadius: "8px", borderRadius: "8px",
backgroundColor: theme.palette.background.default, filter: unpopular ? "saturate(0.6) brightness(0.85)" : null,
border: isSelected ? `1px solid ${theme.palette.purple.main}` : `1px solid ${theme.palette.gray.main}`, backgroundColor: theme.palette.background.default,
gap: "20px", border: isSelected
alignItems: "center", ? `1px solid ${theme.palette.purple.main}`
flexWrap: "wrap", : `1px solid ${theme.palette.gray.main}`,
boxShadow: isSelected ? `0 0 0 1.5px ${theme.palette.purple.main};` : "none", gap: "15px",
"&:hover": { alignItems: "center",
backgroundColor: theme.palette.purple.main, flexWrap: "wrap",
border: `1px solid ${theme.palette.purple.main}`, boxShadow: isSelected
"& > p": { ? `0 0 0 1.5px ${theme.palette.purple.main};`
color: "white", : "none",
} "&:hover": {
}, backgroundColor: theme.palette.purple.main,
}} border: `1px solid ${theme.palette.purple.main}`,
onClick={onClick} "& > p": {
> color: "white",
<img src={image} alt="payment method" /> },
<Typography sx={{ color: theme.palette.gray.dark }}>{name}</Typography> },
</Button> }}
) onClick={onClick}
>
<img src={image} alt="payment method" />
<Typography sx={{ color: theme.palette.gray.dark }}>{label}</Typography>
</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>
);
};

@ -1,327 +1,341 @@
import { import {
Box, Box,
Button, Button,
Fab, Fab,
FormControl, FormControl,
IconButton, IconButton,
InputAdornment, InputAdornment,
InputBase, InputBase,
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,
incrementMessageApiPage, incrementMessageApiPage,
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",
ticketId, ticketId,
messagesPerPage, messagesPerPage,
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 +
onDisconnect: useCallback(() => { `/heruvym/ticket?ticket=${ticketId}&Authorization=${token}`,
clearMessageState() onNewData: (ticketMessages) => {
setIsPreventAutoscroll(false) updateSSEValue(ticketMessages);
}, []), addOrUpdateMessages(ticketMessages);
marker: "ticket message", },
}) onDisconnect: useCallback(() => {
clearMessageState();
setIsPreventAutoscroll(false);
}, []),
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, {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}) + }) +
" " + " " +
createdAt.toLocaleTimeString(undefined, { createdAt.toLocaleTimeString(undefined, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}) });
return ( return (
<Box <Box
sx={{ sx={{
backgroundColor: upMd ? "white" : undefined, backgroundColor: upMd ? "white" : undefined,
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
maxHeight: upMd ? "443px" : undefined, maxHeight: upMd ? "443px" : undefined,
borderRadius: "12px", borderRadius: "12px",
p: upMd ? "20px" : undefined, p: upMd ? "20px" : undefined,
gap: "40px", gap: "40px",
height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null, height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null,
boxShadow: upMd ? cardShadow : undefined, boxShadow: upMd ? cardShadow : undefined,
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "start", alignItems: "start",
flexDirection: "column", flexDirection: "column",
flexGrow: 1, flexGrow: 1,
}} }}
> >
<Typography variant={upMd ? "h5" : "body2"} mb={"4px"}> <Typography variant={upMd ? "h5" : "body2"} mb={"4px"}>
{ticket?.title} {ticket?.title}
</Typography> </Typography>
<Typography <Typography
sx={{ sx={{
fontWeight: 400, fontWeight: 400,
fontSize: "14px", fontSize: "14px",
lineHeight: "17px", lineHeight: "17px",
color: theme.palette.gray.main, color: theme.palette.gray.main,
mb: upMd ? "9px" : "20px", mb: upMd ? "9px" : "20px",
}} }}
> >
Создан: {createdAtString} Создан: {createdAtString}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
backgroundColor: "#ECECF3", backgroundColor: "#ECECF3",
border: `1px solid ${theme.palette.gray.main}`, border: `1px solid ${theme.palette.gray.main}`,
borderRadius: "10px", borderRadius: "10px",
overflow: "hidden", overflow: "hidden",
width: "100%", width: "100%",
minHeight: "345px", minHeight: "345px",
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
flexDirection: "column", flexDirection: "column",
}} }}
> >
<Box <Box
sx={{ sx={{
position: "relative", position: "relative",
width: "100%", width: "100%",
flexGrow: 1, flexGrow: 1,
borderBottom: `1px solid ${theme.palette.gray.main}`, borderBottom: `1px solid ${theme.palette.gray.main}`,
height: "200px", height: "200px",
}} }}
> >
{isPreventAutoscroll && ( {isPreventAutoscroll && (
<Fab <Fab
size="small" size="small"
onClick={() => scrollToBottom("smooth")} onClick={() => scrollToBottom("smooth")}
sx={{ sx={{
position: "absolute", position: "absolute",
left: "10px", left: "10px",
bottom: "10px", bottom: "10px",
}} }}
> >
<ArrowDownwardIcon /> <ArrowDownwardIcon />
</Fab> </Fab>
)} )}
<Box <Box
ref={chatBoxRef} ref={chatBoxRef}
sx={{ sx={{
display: "flex", display: "flex",
width: "100%", width: "100%",
flexDirection: "column", flexDirection: "column",
gap: upMd ? "20px" : "16px", gap: upMd ? "20px" : "16px",
px: upMd ? "20px" : "5px", px: upMd ? "20px" : "5px",
py: upMd ? "20px" : "13px", py: upMd ? "20px" : "13px",
overflowY: "auto", overflowY: "auto",
height: "100%", height: "100%",
}} }}
> >
{ticket && {ticket &&
messages.map((message) => ( messages.map((message) => (
<ChatMessage <ChatMessage
key={message.id} key={message.id}
text={message.message} text={message.message}
createdAt={message.created_at} createdAt={message.created_at}
isSelf={ticket.user === message.user_id} isSelf={ticket.user === message.user_id}
/> />
))} ))}
</Box> </Box>
</Box> </Box>
<FormControl> <FormControl>
<InputBase <InputBase
value={messageField} value={messageField}
fullWidth fullWidth
placeholder="Текст обращения" placeholder="Текст обращения"
id="message" id="message"
multiline multiline
sx={{ sx={{
width: "100%", width: "100%",
p: 0, p: 0,
}} }}
inputProps={{ inputProps={{
sx: { sx: {
fontWeight: 400, fontWeight: 400,
fontSize: "16px", fontSize: "16px",
lineHeight: "19px", lineHeight: "19px",
pt: upMd ? "13px" : "28px", pt: upMd ? "13px" : "28px",
pb: upMd ? "13px" : "24px", pb: upMd ? "13px" : "24px",
px: "19px", px: "19px",
maxHeight: "calc(19px * 5)", maxHeight: "calc(19px * 5)",
}, },
}} }}
onChange={(e) => setMessageField(e.target.value)} onChange={(e) => setMessageField(e.target.value)}
endAdornment={ endAdornment={
!upMd && ( !upMd && (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton <IconButton
onClick={handleSendMessage} onClick={handleSendMessage}
sx={{ sx={{
height: "45px", height: "45px",
width: "45px", width: "45px",
mr: "13px", mr: "13px",
p: 0, p: 0,
}} }}
> >
<SendIcon /> <SendIcon />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) )
} }
/> />
</FormControl> </FormControl>
</Box> </Box>
</Box> </Box>
{upMd && ( {upMd && (
<Box sx={{ alignSelf: "end" }}> <Box sx={{ alignSelf: "end" }}>
<Button <Button
variant="pena-contained-dark" variant="pena-contained-dark"
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={!messageField} disabled={!messageField}
> >
Отправить Отправить
</Button> </Button>
</Box> </Box>
)} )}
</Box> </Box>
) );
} }
export default withErrorBoundary(SupportChat, { export default withErrorBoundary(SupportChat, {
fallback: <Typography mt="8px" textAlign="center">Не удалось отобразить чат</Typography>, fallback: (
onError: handleComponentError, <Typography mt="8px" textAlign="center">
}) Не удалось отобразить чат
</Typography>
),
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 };
};