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 { 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}`;
}
};

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>
);
}

@ -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,
});

@ -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 };
};