Merge branch 'dev' into 'staging'
Dev See merge request frontend/marketplace!125
This commit is contained in:
commit
e3c43782f7
@ -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<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}`;
|
||||
}
|
||||
};
|
||||
|
BIN
src/assets/bank-logo/rs-pay.png
Normal file
BIN
src/assets/bank-logo/rs-pay.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
@ -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<HTMLDivElement>(null);
|
||||
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
|
||||
"ticket",
|
||||
addOrUpdateUnauthMessages
|
||||
);
|
||||
|
||||
useTicketMessages({
|
||||
url: process.env.REACT_APP_DOMAIN + "/heruvym/getMessages",
|
||||
@ -81,11 +86,14 @@ export default function Chat({ open = false, sx }: Props) {
|
||||
});
|
||||
|
||||
useSSESubscription<TicketMessage>({
|
||||
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);
|
||||
}, []),
|
||||
|
@ -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<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>({
|
||||
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 (
|
||||
<Navbar>
|
||||
<Outlet />
|
||||
</Navbar>
|
||||
)
|
||||
return (
|
||||
<Navbar>
|
||||
<Outlet />
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
@ -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<PaymentMethod | null>(null)
|
||||
const [paymentValueField, setPaymentValueField] = useState<string>("0")
|
||||
const [paymentLink, setPaymentLink] = useState<string>("")
|
||||
const [fromSquiz, setIsFromSquiz] = useState<boolean>(false)
|
||||
const location = useLocation()
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] =
|
||||
useState<PaymentMethodType | null>(null);
|
||||
const [warnModalOpen, setWarnModalOpen] = useState<boolean>(false);
|
||||
const [paymentValueField, setPaymentValueField] = useState<string>("0");
|
||||
const [paymentLink, setPaymentLink] = useState<string>("");
|
||||
const [fromSquiz, setIsFromSquiz] = useState<boolean>(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 (
|
||||
<SectionWrapper
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
mt: "25px",
|
||||
mb: "70px",
|
||||
px: isTablet ? (upMd ? "40px" : "18px") : "20px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: "20px",
|
||||
mb: "40px",
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
{!upMd && (
|
||||
<IconButton
|
||||
onClick={handleCustomBackNavigation}
|
||||
sx={{ p: 0, height: "28px", width: "28px", color: "black" }}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h4">Способ оплаты</Typography>
|
||||
</Box>
|
||||
{!upMd && (
|
||||
<Typography variant="body2" mb="30px">
|
||||
if (sendPaymentResponse) {
|
||||
setPaymentLink(sendPaymentResponse.link);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const handleCustomBackNavigation = useHistoryTracker();
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
maxWidth="lg"
|
||||
sx={{
|
||||
mt: "25px",
|
||||
mb: "70px",
|
||||
px: isTablet ? (upMd ? "40px" : "18px") : "20px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mt: "20px",
|
||||
mb: "40px",
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
{!upMd && (
|
||||
<IconButton
|
||||
onClick={handleCustomBackNavigation}
|
||||
sx={{ p: 0, height: "28px", width: "28px", color: "black" }}
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h4">Способ оплаты</Typography>
|
||||
</Box>
|
||||
{!upMd && (
|
||||
<Typography variant="body2" mb="30px">
|
||||
Выберите способ оплаты
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: upMd ? "white" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: upMd ? "row" : "column",
|
||||
borderRadius: "12px",
|
||||
boxShadow: upMd ? cardShadow : undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: upMd ? "68.5%" : undefined,
|
||||
p: upMd ? "20px" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: upSm ? "row" : "column",
|
||||
flexWrap: "wrap",
|
||||
gap: upMd ? "14px" : "20px",
|
||||
alignContent: "start",
|
||||
}}
|
||||
>
|
||||
{paymentMethods.map((method) => (
|
||||
<PaymentMethodCard
|
||||
isSelected={selectedPaymentMethod === method.name}
|
||||
key={method.name}
|
||||
name={method.name}
|
||||
image={method.image}
|
||||
onClick={() => setSelectedPaymentMethod(method.name)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "start",
|
||||
color: theme.palette.gray.dark,
|
||||
width: upMd ? "31.5%" : undefined,
|
||||
p: upMd ? "20px" : undefined,
|
||||
pl: upMd ? "33px" : undefined,
|
||||
mt: upMd ? undefined : "30px",
|
||||
borderLeft: upMd
|
||||
? `1px solid ${theme.palette.gray.main}`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "85%",
|
||||
}}
|
||||
>
|
||||
{upMd && <Typography mb="56px">Выберите способ оплаты</Typography>}
|
||||
<Typography mb="20px">К оплате</Typography>
|
||||
{paymentLink ? (
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: "20px",
|
||||
lineHeight: "48px",
|
||||
mb: "28px",
|
||||
}}
|
||||
>
|
||||
{currencyFormatter.format(paymentValue / 100)}
|
||||
</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}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: upMd ? "white" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: upMd ? "row" : "column",
|
||||
borderRadius: "12px",
|
||||
boxShadow: upMd ? cardShadow : undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: upMd ? "68.5%" : undefined,
|
||||
p: upMd ? "20px" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: upSm ? "row" : "column",
|
||||
flexWrap: "wrap",
|
||||
gap: upMd ? "14px" : "20px",
|
||||
alignContent: "start",
|
||||
}}
|
||||
>
|
||||
{paymentMethods.map(({ name, label, image, unpopular = false }) => (
|
||||
<PaymentMethodCard
|
||||
isSelected={selectedPaymentMethod === name}
|
||||
key={name}
|
||||
label={label}
|
||||
image={image}
|
||||
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
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "start",
|
||||
color: theme.palette.gray.dark,
|
||||
width: upMd ? "31.5%" : undefined,
|
||||
p: upMd ? "20px" : undefined,
|
||||
pl: upMd ? "33px" : undefined,
|
||||
mt: upMd ? undefined : "30px",
|
||||
borderLeft: upMd
|
||||
? `1px solid ${theme.palette.gray.main}`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "85%",
|
||||
}}
|
||||
>
|
||||
{upMd && <Typography mb="56px">Выберите способ оплаты</Typography>}
|
||||
<Typography mb="20px">К оплате</Typography>
|
||||
{paymentLink ? (
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: "20px",
|
||||
lineHeight: "48px",
|
||||
mb: "28px",
|
||||
}}
|
||||
>
|
||||
{currencyFormatter.format(paymentValue / 100)}
|
||||
</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
|
||||
variant="pena-outlined-light"
|
||||
disabled={!isFinite(paymentValue)}
|
||||
onClick={handleChoosePaymentClick}
|
||||
sx={{
|
||||
mt: "auto",
|
||||
color: "black",
|
||||
border: `1px solid ${theme.palette.purple.main}`,
|
||||
"&:hover": {
|
||||
color: "white",
|
||||
},
|
||||
"&:active": {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="pena-outlined-light"
|
||||
disabled={!isFinite(paymentValue)}
|
||||
onClick={handleChoosePaymentClick}
|
||||
sx={{
|
||||
mt: "auto",
|
||||
color: "black",
|
||||
border: `1px solid ${theme.palette.purple.main}`,
|
||||
"&:hover": {
|
||||
color: "white",
|
||||
},
|
||||
"&:active": {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionWrapper>
|
||||
)
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<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 {
|
||||
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 (
|
||||
<Button
|
||||
sx={{
|
||||
width: upSm ? "237px" : "100%",
|
||||
p: "20px",
|
||||
pr: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
border: isSelected ? `1px solid ${theme.palette.purple.main}` : `1px solid ${theme.palette.gray.main}`,
|
||||
gap: "20px",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
boxShadow: isSelected ? `0 0 0 1.5px ${theme.palette.purple.main};` : "none",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.purple.main,
|
||||
border: `1px solid ${theme.palette.purple.main}`,
|
||||
"& > p": {
|
||||
color: "white",
|
||||
}
|
||||
},
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img src={image} alt="payment method" />
|
||||
<Typography sx={{ color: theme.palette.gray.dark }}>{name}</Typography>
|
||||
</Button>
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
sx={{
|
||||
width: upSm ? "237px" : "100%",
|
||||
p: "20px",
|
||||
pr: "10px",
|
||||
display: "flex",
|
||||
justifyContent: "start",
|
||||
borderRadius: "8px",
|
||||
filter: unpopular ? "saturate(0.6) brightness(0.85)" : null,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
border: isSelected
|
||||
? `1px solid ${theme.palette.purple.main}`
|
||||
: `1px solid ${theme.palette.gray.main}`,
|
||||
gap: "15px",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1.5px ${theme.palette.purple.main};`
|
||||
: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.purple.main,
|
||||
border: `1px solid ${theme.palette.purple.main}`,
|
||||
"& > p": {
|
||||
color: "white",
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img src={image} alt="payment method" />
|
||||
<Typography sx={{ color: theme.palette.gray.dark }}>{label}</Typography>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
61
src/pages/Payment/WarnModal.tsx
Normal file
61
src/pages/Payment/WarnModal.tsx
Normal file
@ -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 {
|
||||
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<string>("")
|
||||
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<HTMLDivElement>(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<string>("");
|
||||
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<HTMLDivElement>(null);
|
||||
const fetchState = useMessageStore((state) => state.ticketMessageFetchState);
|
||||
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
|
||||
"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<TicketMessage>({
|
||||
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<TicketMessage>({
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: upMd ? "white" : undefined,
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
maxHeight: upMd ? "443px" : undefined,
|
||||
borderRadius: "12px",
|
||||
p: upMd ? "20px" : undefined,
|
||||
gap: "40px",
|
||||
height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null,
|
||||
boxShadow: upMd ? cardShadow : undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant={upMd ? "h5" : "body2"} mb={"4px"}>
|
||||
{ticket?.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
fontSize: "14px",
|
||||
lineHeight: "17px",
|
||||
color: theme.palette.gray.main,
|
||||
mb: upMd ? "9px" : "20px",
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: upMd ? "white" : undefined,
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
maxHeight: upMd ? "443px" : undefined,
|
||||
borderRadius: "12px",
|
||||
p: upMd ? "20px" : undefined,
|
||||
gap: "40px",
|
||||
height: !upMd ? `calc(100% - ${isMobile ? 90 : 115}px)` : null,
|
||||
boxShadow: upMd ? cardShadow : undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "start",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant={upMd ? "h5" : "body2"} mb={"4px"}>
|
||||
{ticket?.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
fontSize: "14px",
|
||||
lineHeight: "17px",
|
||||
color: theme.palette.gray.main,
|
||||
mb: upMd ? "9px" : "20px",
|
||||
}}
|
||||
>
|
||||
Создан: {createdAtString}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "#ECECF3",
|
||||
border: `1px solid ${theme.palette.gray.main}`,
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
minHeight: "345px",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
flexGrow: 1,
|
||||
borderBottom: `1px solid ${theme.palette.gray.main}`,
|
||||
height: "200px",
|
||||
}}
|
||||
>
|
||||
{isPreventAutoscroll && (
|
||||
<Fab
|
||||
size="small"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
bottom: "10px",
|
||||
}}
|
||||
>
|
||||
<ArrowDownwardIcon />
|
||||
</Fab>
|
||||
)}
|
||||
<Box
|
||||
ref={chatBoxRef}
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
gap: upMd ? "20px" : "16px",
|
||||
px: upMd ? "20px" : "5px",
|
||||
py: upMd ? "20px" : "13px",
|
||||
overflowY: "auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{ticket &&
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: "#ECECF3",
|
||||
border: `1px solid ${theme.palette.gray.main}`,
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
minHeight: "345px",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
flexGrow: 1,
|
||||
borderBottom: `1px solid ${theme.palette.gray.main}`,
|
||||
height: "200px",
|
||||
}}
|
||||
>
|
||||
{isPreventAutoscroll && (
|
||||
<Fab
|
||||
size="small"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
bottom: "10px",
|
||||
}}
|
||||
>
|
||||
<ArrowDownwardIcon />
|
||||
</Fab>
|
||||
)}
|
||||
<Box
|
||||
ref={chatBoxRef}
|
||||
sx={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
gap: upMd ? "20px" : "16px",
|
||||
px: upMd ? "20px" : "5px",
|
||||
py: upMd ? "20px" : "13px",
|
||||
overflowY: "auto",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{ticket &&
|
||||
messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
text={message.message}
|
||||
createdAt={message.created_at}
|
||||
isSelf={ticket.user === message.user_id}
|
||||
/>
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
text={message.message}
|
||||
createdAt={message.created_at}
|
||||
isSelf={ticket.user === message.user_id}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<FormControl>
|
||||
<InputBase
|
||||
value={messageField}
|
||||
fullWidth
|
||||
placeholder="Текст обращения"
|
||||
id="message"
|
||||
multiline
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: 0,
|
||||
}}
|
||||
inputProps={{
|
||||
sx: {
|
||||
fontWeight: 400,
|
||||
fontSize: "16px",
|
||||
lineHeight: "19px",
|
||||
pt: upMd ? "13px" : "28px",
|
||||
pb: upMd ? "13px" : "24px",
|
||||
px: "19px",
|
||||
maxHeight: "calc(19px * 5)",
|
||||
},
|
||||
}}
|
||||
onChange={(e) => setMessageField(e.target.value)}
|
||||
endAdornment={
|
||||
!upMd && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleSendMessage}
|
||||
sx={{
|
||||
height: "45px",
|
||||
width: "45px",
|
||||
mr: "13px",
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
{upMd && (
|
||||
<Box sx={{ alignSelf: "end" }}>
|
||||
<Button
|
||||
variant="pena-contained-dark"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageField}
|
||||
>
|
||||
</Box>
|
||||
</Box>
|
||||
<FormControl>
|
||||
<InputBase
|
||||
value={messageField}
|
||||
fullWidth
|
||||
placeholder="Текст обращения"
|
||||
id="message"
|
||||
multiline
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: 0,
|
||||
}}
|
||||
inputProps={{
|
||||
sx: {
|
||||
fontWeight: 400,
|
||||
fontSize: "16px",
|
||||
lineHeight: "19px",
|
||||
pt: upMd ? "13px" : "28px",
|
||||
pb: upMd ? "13px" : "24px",
|
||||
px: "19px",
|
||||
maxHeight: "calc(19px * 5)",
|
||||
},
|
||||
}}
|
||||
onChange={(e) => setMessageField(e.target.value)}
|
||||
endAdornment={
|
||||
!upMd && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={handleSendMessage}
|
||||
sx={{
|
||||
height: "45px",
|
||||
width: "45px",
|
||||
mr: "13px",
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
{upMd && (
|
||||
<Box sx={{ alignSelf: "end" }}>
|
||||
<Button
|
||||
variant="pena-contained-dark"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageField}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default withErrorBoundary(SupportChat, {
|
||||
fallback: <Typography mt="8px" textAlign="center">Не удалось отобразить чат</Typography>,
|
||||
onError: handleComponentError,
|
||||
})
|
||||
fallback: (
|
||||
<Typography mt="8px" textAlign="center">
|
||||
Не удалось отобразить чат
|
||||
</Typography>
|
||||
),
|
||||
onError: handleComponentError,
|
||||
});
|
||||
|
93
src/utils/hooks/useSSETab.ts
Normal file
93
src/utils/hooks/useSSETab.ts
Normal file
@ -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 };
|
||||
};
|
Loading…
Reference in New Issue
Block a user