Merge branch 'dev' into cart-calc

This commit is contained in:
nflnkr 2024-03-18 17:19:58 +03:00
commit b197745563
40 changed files with 2054 additions and 784 deletions

@ -1,34 +1,16 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"react"
],
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"never"
]
}
"env": {
"browser": true,
"es2021": true
},
"extends": [],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "react"],
"rules": {
"quotes": ["error", "double"]
}
}

@ -59,7 +59,7 @@
"eslint-plugin-react": "^7.33.2",
"jest": "^29.5.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.3"
"typescript": "^5.4.2"
},
"browserslist": {
"production": [

@ -17,6 +17,25 @@ export type HistoryRecord = {
userId: string;
};
export interface GetHistoryResponse2 {
totalPages: number;
records: HistoryRecord[];
}
export type HistoryRecord2 = {
comment: string;
createdAt: string;
id: string;
isDeleted: boolean;
key: string;
rawDetails: {
price: number;
tariffs: Tariff[];
};
updatedAt: string;
userId: string;
};
type RawDetails = { Key: string; Value: KeyValue[][] }
type KeyValue = { Key: string; Value: string | number };
@ -25,7 +44,7 @@ const regList:Record<string, number> = {
"price": 1
}
export async function getHistory(): Promise<[GetHistoryResponse | null, string?]> {
export async function getHistory(): Promise<[GetHistoryResponse | GetHistoryResponse2 | null, string?]> {
try {
const historyResponse = await makeRequest<never, GetHistoryResponse>({
url: process.env.REACT_APP_DOMAIN + "/customer/history?page=1&limit=100&type=payCart",
@ -33,6 +52,10 @@ export async function getHistory(): Promise<[GetHistoryResponse | null, string?]
useToken: true,
})
if (!Array.isArray(historyResponse.records[0]?.rawDetails)) {
return [historyResponse] as [GetHistoryResponse2]
}
const checked = historyResponse.records.map((data) => {
console.log(data.rawDetails)
const buffer:KeyValue[] = []

@ -6,7 +6,9 @@ import type { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import type { GetTariffsResponse } from "@root/model/tariff";
import { removeTariffFromCart } from "@root/stores/user";
const apiUrl = process.env.REACT_APP_DOMAIN + "/strator";
const apiUrl = process.env.REACT_APP_DOMAIN + "/strator"
console.log("домен с которого тарифы запрашиваются", process.env.REACT_APP_DOMAIN)
console.log("домен с которого тарифы запрашиваются", apiUrl)
export async function getTariffs(
apiPage: number,

@ -1,46 +1,46 @@
import { makeRequest } from "@frontend/kitui"
import { parseAxiosError } from "@root/utils/parse-error"
import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
import { SendTicketMessageRequest } from "@frontend/kitui"
import { SendTicketMessageRequest } from "@frontend/kitui";
const apiUrl = process.env.REACT_APP_DOMAIN + "/heruvym"
const apiUrl = process.env.REACT_APP_DOMAIN + "/heruvym";
export async function sendTicketMessage(
ticketId: string,
message: string
ticketId: string,
message: string
): Promise<[null, string?]> {
try {
const sendTicketMessageResponse = await makeRequest<
try {
const sendTicketMessageResponse = await makeRequest<
SendTicketMessageRequest,
null
>({
url: `${apiUrl}/send`,
method: "POST",
useToken: true,
body: { ticket: ticketId, message: message, lang: "ru", files: [] },
})
url: `${apiUrl}/send`,
method: "POST",
useToken: true,
body: { ticket: ticketId, message: message, lang: "ru", files: [] },
});
return [sendTicketMessageResponse]
} catch (nativeError) {
const [error] = parseAxiosError(nativeError)
return [sendTicketMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось отправить сообщение. ${error}`]
}
return [null, `Не удалось отправить сообщение. ${error}`];
}
}
export async function shownMessage(id: string): Promise<[null, string?]> {
try {
const shownMessageResponse = await makeRequest<{ id: string }, null>({
url: apiUrl + "/shown",
method: "POST",
useToken: true,
body: { id },
})
try {
const shownMessageResponse = await makeRequest<{ id: string }, null>({
url: apiUrl + "/shown",
method: "POST",
useToken: true,
body: { id },
});
return [shownMessageResponse]
} catch (nativeError) {
const [error] = parseAxiosError(nativeError)
return [shownMessageResponse];
} catch (nativeError) {
const [error] = parseAxiosError(nativeError);
return [null, `Не удалось прочесть сообщение. ${error}`]
}
return [null, `Не удалось прочесть сообщение. ${error}`];
}
}

@ -0,0 +1,40 @@
import { Box } from "@mui/material";
interface Props {
color: string;
}
export default function ArrowLeft({ color = "#7E2AEA" }: Props) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.75 12H4.25"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 5.25L4.25 12L11 18.75"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Box>
);
}

@ -0,0 +1,55 @@
import { Box, SxProps, Theme } from "@mui/material";
interface Props {
color: string;
sx?: SxProps<Theme>;
}
export default function Download({ color, sx }: Props) {
return (
<Box
sx={{
height: "38px",
width: "45px",
display: "flex",
alignItems: "center",
justifyContent: "center",
...sx,
}}
>
<svg
width="47"
height="42"
viewBox="0 0 47 42"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33.8846 26.8076H44.2692C44.7283 26.8076 45.1685 26.9551 45.4931 27.2177C45.8177 27.4802 46 27.8363 46 28.2076V39.4076C46 39.7789 45.8177 40.135 45.4931 40.3976C45.1685 40.6601 44.7283 40.8076 44.2692 40.8076H2.73077C2.27174 40.8076 1.83151 40.6601 1.50693 40.3976C1.18235 40.135 1 39.7789 1 39.4076V28.2076C1 27.8363 1.18235 27.4802 1.50693 27.2177C1.83151 26.9551 2.27174 26.8076 2.73077 26.8076H13.1154"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M23.5 27V1"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.1155 11.3846L23.5001 1L33.8847 11.3846"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M39.135 36.0776C40.3141 36.0776 41.27 35.1217 41.27 33.9426C41.27 32.7635 40.3141 31.8076 39.135 31.8076C37.9559 31.8076 37 32.7635 37 33.9426C37 35.1217 37.9559 36.0776 39.135 36.0776Z"
fill={color}
/>
</svg>
</Box>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -18,8 +18,6 @@ export default function CustomAccordion({ header, text, divide = false, price, l
const upXs = useMediaQuery(theme.breakpoints.up("xs")); //300
const [isExpanded, setIsExpanded] = useState<boolean>(false);
console.log(upXs);
return (
<Box
sx={{

@ -1,5 +1,5 @@
import { useState, useRef } from "react";
import { Typography, Drawer, useMediaQuery, useTheme, Box, IconButton, Badge, Button } from "@mui/material";
import { Typography, Drawer, useMediaQuery, useTheme, Box, IconButton, Badge, Button, Alert } from "@mui/material";
import SectionWrapper from "./SectionWrapper";
import CustomWrapperDrawer from "./CustomWrapperDrawer";
import { NotificationsModal } from "./NotificationsModal";
@ -22,327 +22,341 @@ import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { setNotEnoughMoneyAmount, useCartStore } from "@root/stores/cart";
function Drawers() {
const [openNotificationsModal, setOpenNotificationsModal] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const bellRef = useRef<HTMLButtonElement | null>(null);
const navigate = useNavigate();
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const cart = useCart();
const userAccount = useUserStore((state) => state.userAccount);
const tickets = useTicketStore((state) => state.tickets);
const notEnoughMoneyAmount = useCartStore(state => state.notEnoughMoneyAmount);
const [openNotificationsModal, setOpenNotificationsModal] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const bellRef = useRef<HTMLButtonElement | null>(null);
const navigate = useNavigate();
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const cart = useCart();
const userAccount = useUserStore((state) => state.userAccount);
const tickets = useTicketStore((state) => state.tickets);
const notEnoughMoneyAmount = useCartStore(state => state.notEnoughMoneyAmount);
const notificationsCount = tickets.filter(
({ user, top_message }) => user !== top_message.user_id && top_message.shown.me !== 1
).length;
const notificationsCount = tickets.filter(
({ user, top_message }) => user !== top_message.user_id && top_message.shown.me !== 1
).length;
async function handlePayClick() {
setLoading(true);
async function handlePayClick() {
setLoading(true);
const [payCartResponse, payCartError] = await payCart();
const [payCartResponse, payCartError] = await payCart();
if (payCartError) {
if (payCartError.includes("insufficient funds: ")) {
const notEnoughMoneyAmount = parseInt(payCartError.replace(/^.*insufficient\sfunds:\s(?=\d+$)/, ""));
setNotEnoughMoneyAmount(notEnoughMoneyAmount);
}
if (payCartError) {
if (payCartError.includes("insufficient funds: ")) {
const notEnoughMoneyAmount = parseInt(payCartError.replace(/^.*insufficient\sfunds:\s(?=\d+$)/, ""));
setNotEnoughMoneyAmount(notEnoughMoneyAmount);
}
setLoading(false);
setLoading(false);
setIsDrawerOpen(false);
navigate("payment")
if (!payCartError.includes("insufficient funds: ")) enqueueSnackbar(payCartError);
return
if (!payCartError.includes("insufficient funds: ")) enqueueSnackbar(payCartError);
return;
}
if (payCartResponse) {
setUserAccount(payCartResponse);
}
setLoading(false);
setIsDrawerOpen(false);
}
if (payCartResponse) {
setUserAccount(payCartResponse);
function handleReplenishWallet() {
setIsDrawerOpen(false);
navigate("/payment", { state: { notEnoughMoneyAmount } });
}
setLoading(false);
setIsDrawerOpen(false);;
}
function handleReplenishWallet() {
navigate("/payment", { state: { notEnoughMoneyAmount } });
}
return (
<Box sx={{ display: "flex", gap: isTablet ? "10px" : "20px" }}>
<IconButton
ref={bellRef}
aria-label="cart"
onClick={() => setOpenNotificationsModal((isOpened) => !isOpened)}
sx={{
cursor: "pointer",
borderRadius: "6px",
background: openNotificationsModal ? theme.palette.purple.main : theme.palette.background.default,
"& .MuiBadge-badge": {
background: openNotificationsModal ? theme.palette.background.default : theme.palette.purple.main,
color: openNotificationsModal ? theme.palette.purple.main : theme.palette.background.default,
},
"& svg > path:first-of-type": {
fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
},
"& svg > path:last-child": {
stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
},
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:first-of-type": { fill: "#FFFFFF" },
"& svg > path:last-child": { stroke: "#FFFFFF" },
},
}}
>
<Badge
badgeContent={notificationsCount}
sx={{
"& .MuiBadge-badge": {
display: notificationsCount ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<BellIcon />
</Badge>
</IconButton>
<NotificationsModal
open={openNotificationsModal}
setOpen={setOpenNotificationsModal}
anchorElement={bellRef.current}
notifications={tickets
.filter(({ user, top_message }) => user !== top_message.user_id)
.map((ticket) => ({
text: "У вас новое сообщение от техподдержки",
date: new Date(ticket.updated_at).toLocaleDateString(),
url: `/support/${ticket.id}`,
watched: ticket.user === ticket.top_message.user_id || ticket.top_message.shown.me === 1,
}))}
/>
<IconButton
onClick={() => setIsDrawerOpen(true)}
component="div"
sx={{
cursor: "pointer",
background: theme.palette.background.default,
borderRadius: "6px",
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:nth-of-type(1)": { fill: "#FFFFFF" },
"& svg > path:nth-of-type(2)": { fill: "#FFFFFF" },
"& svg > path:nth-of-type(3)": { stroke: "#FFFFFF" },
},
}}
>
<Badge
badgeContent={userAccount?.cart.length}
sx={{
"& .MuiBadge-badge": {
display: userAccount?.cart.length ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<CartIcon />
</Badge>
</IconButton>
<Drawer anchor={"right"} open={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} sx={{ background: "rgba(0, 0, 0, 0.55)" }}>
<SectionWrapper
maxWidth="lg"
sx={{
pl: "0px",
pr: "0px",
width: "450px",
}}
>
<Box
sx={{
width: "100%",
pt: "12px",
pb: "12px",
display: "flex",
justifyContent: "space-between",
bgcolor: "#F2F3F7",
gap: "10px",
pl: "20px",
pr: "20px",
}}
>
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
>
Корзина
</Typography>
<IconButton onClick={() => setIsDrawerOpen(false)} sx={{ p: 0 }}>
<CrossIcon />
</IconButton>
</Box>
<Box sx={{ pl: "20px", pr: "20px" }}>
{cart.services.map((serviceData) => {
return (
<CustomWrapperDrawer key={serviceData.serviceKey} serviceData={serviceData} />
)})}
<Box
sx={{
mt: "40px",
pt: upMd ? "30px" : undefined,
borderTop: upMd ? `1px solid ${theme.palette.gray.main}` : undefined,
}}
>
<Box
return (
<Box sx={{ display: "flex", gap: isTablet ? "10px" : "20px" }}>
<IconButton
ref={bellRef}
aria-label="cart"
onClick={() => setOpenNotificationsModal((isOpened) => !isOpened)}
sx={{
width: upMd ? "100%" : undefined,
display: "flex",
flexWrap: "wrap",
flexDirection: "column",
cursor: "pointer",
borderRadius: "6px",
background: openNotificationsModal ? theme.palette.purple.main : theme.palette.background.default,
"& .MuiBadge-badge": {
background: openNotificationsModal ? theme.palette.background.default : theme.palette.purple.main,
color: openNotificationsModal ? theme.palette.purple.main : theme.palette.background.default,
},
"& svg > path:first-of-type": {
fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
},
"& svg > path:last-child": {
stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
},
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:first-of-type": { fill: "#FFFFFF" },
"& svg > path:last-child": { stroke: "#FFFFFF" },
},
}}
>
<Typography variant="h4" mb={upMd ? "18px" : "30px"}>
Итоговая цена
</Typography>
<Typography color={theme.palette.gray.dark} mb={upMd ? "18px" : "30px"}>
Здесь написана окончательная стоимость всех услуг сложенных в корзину с учётом всех скидок.
</Typography>
<Typography color={theme.palette.gray.dark}>
После нажатия кнопки оплатить, вы будете перенаправлены на форму оплаты, для оплаты ВСЕЙ корзины (рекомендуем перед оплатой, убрать все лишнее)
</Typography>
</Box>
<Box
sx={{
color: theme.palette.gray.dark,
pb: "100px",
pt: "38px",
}}
>
>
<Badge
badgeContent={
cart.priceBeforeDiscounts - cart.priceAfterDiscounts ? (
<span
style={{
backgroundColor: "#ff4904",
color: "white",
fontSize: "14px",
}}
>
-
{`${
((cart.priceBeforeDiscounts - cart.priceAfterDiscounts) / (cart.priceBeforeDiscounts / 100)).toFixed(0)
}%`}
</span>
) : null
}
color="success"
sx={{
"& .MuiBadge-dot": {
backgroundColor: "#ff4904",
width: "10px",
height: "10px",
},
"& .MuiBadge-anchorOriginTopRightRectangle": {
backgroundColor: "#ff4904",
top: "5px",
right: "5px",
},
"& .MuiBadge-anchorOriginTopRightRectangular": {
backgroundColor: "#ff4904",
height: "31px",
padding: "5px 10px",
right: "20px",
},
}}
>
<Box
badgeContent={notificationsCount}
sx={{
display: "flex",
flexDirection: upMd ? "column" : "row",
alignItems: upMd ? "start" : "center",
mt: upMd ? "10px" : "30px",
gap: "15px",
"& .MuiBadge-badge": {
display: notificationsCount ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<Typography
color={theme.palette.orange.main}
sx={{
textDecoration: "line-through",
order: upMd ? 1 : 2,
}}
>
{currencyFormatter.format(cart.priceBeforeDiscounts / 100)}
</Typography>
<Typography
variant="p1"
sx={{
fontWeight: 500,
fontSize: "26px",
lineHeight: "31px",
order: upMd ? 2 : 1,
mr: "20px",
}}
>
{currencyFormatter.format(cart.priceAfterDiscounts / 100)}
</Typography>
</Box>
</Badge>
<Button
disabled = {cart.priceAfterDiscounts === 0}
variant="pena-contained-dark"
onClick={() => (notEnoughMoneyAmount === 0 ? !loading && handlePayClick() : handleReplenishWallet())}
sx={{ mt: "25px", display: "block" }}
>
{loading ? <Loader size={24} /> : notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"}
</Button>
</Box>
</Box>
</Box>
</SectionWrapper>
</Drawer>
</Box>
);
<BellIcon />
</Badge>
</IconButton>
<NotificationsModal
open={openNotificationsModal}
setOpen={setOpenNotificationsModal}
anchorElement={bellRef.current}
notifications={tickets
.filter(({ user, top_message }) => user !== top_message.user_id)
.map((ticket) => ({
text: "У вас новое сообщение от техподдержки",
date: new Date(ticket.updated_at).toLocaleDateString(),
url: `/support/${ticket.id}`,
watched: ticket.user === ticket.top_message.user_id || ticket.top_message.shown.me === 1,
}))}
/>
<IconButton
onClick={() => setIsDrawerOpen(true)}
component="div"
sx={{
cursor: "pointer",
background: theme.palette.background.default,
borderRadius: "6px",
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:nth-of-type(1)": { fill: "#FFFFFF" },
"& svg > path:nth-of-type(2)": { fill: "#FFFFFF" },
"& svg > path:nth-of-type(3)": { stroke: "#FFFFFF" },
},
}}
>
<Badge
badgeContent={userAccount?.cart.length}
sx={{
"& .MuiBadge-badge": {
display: userAccount?.cart.length ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<CartIcon />
</Badge>
</IconButton>
<Drawer anchor={"right"} open={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} sx={{ background: "rgba(0, 0, 0, 0.55)" }}>
<SectionWrapper
maxWidth="lg"
sx={{
pl: "0px",
pr: "0px",
width: "450px",
}}
>
<Box
sx={{
width: "100%",
pt: "12px",
pb: "12px",
display: "flex",
justifyContent: "space-between",
bgcolor: "#F2F3F7",
gap: "10px",
pl: "20px",
pr: "20px",
}}
>
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
>
Корзина
</Typography>
<IconButton onClick={() => setIsDrawerOpen(false)} sx={{ p: 0 }}>
<CrossIcon />
</IconButton>
</Box>
<Box sx={{ pl: "20px", pr: "20px" }}>
{cart.services.map((serviceData) => {
return (
<CustomWrapperDrawer key={serviceData.serviceKey} serviceData={serviceData} />
);
})}
<Box
sx={{
mt: "40px",
pt: upMd ? "30px" : undefined,
borderTop: upMd ? `1px solid ${theme.palette.gray.main}` : undefined,
}}
>
<Box
sx={{
width: upMd ? "100%" : undefined,
display: "flex",
flexWrap: "wrap",
flexDirection: "column",
}}
>
<Typography variant="h4" mb={upMd ? "18px" : "30px"}>
Итоговая цена
</Typography>
<Typography color={theme.palette.gray.dark} mb={upMd ? "18px" : "30px"}>
Здесь написана окончательная стоимость всех услуг сложенных в корзину с учётом всех скидок.
</Typography>
<Typography color={theme.palette.gray.dark}>
После нажатия кнопки оплатить, вы будете перенаправлены на форму оплаты, для оплаты ВСЕЙ корзины (рекомендуем перед оплатой, убрать все лишнее)
</Typography>
</Box>
<Box
sx={{
color: theme.palette.gray.dark,
pb: "100px",
pt: "38px",
}}
>
<Badge
badgeContent={
cart.priceBeforeDiscounts - cart.priceAfterDiscounts ? (
<span
style={{
backgroundColor: "#ff4904",
color: "white",
fontSize: "14px",
}}
>
-
{`${((cart.priceBeforeDiscounts - cart.priceAfterDiscounts) / (cart.priceBeforeDiscounts / 100)).toFixed(0)
}%`}
</span>
) : null
}
color="success"
sx={{
"& .MuiBadge-dot": {
backgroundColor: "#ff4904",
width: "10px",
height: "10px",
},
"& .MuiBadge-anchorOriginTopRightRectangle": {
backgroundColor: "#ff4904",
top: "5px",
right: "5px",
},
"& .MuiBadge-anchorOriginTopRightRectangular": {
backgroundColor: "#ff4904",
height: "31px",
padding: "5px 10px",
right: "20px",
},
}}
>
<Box
sx={{
display: "flex",
flexDirection: upMd ? "column" : "row",
alignItems: upMd ? "start" : "center",
mt: upMd ? "10px" : "30px",
gap: "15px",
}}
>
<Typography
color={theme.palette.orange.main}
sx={{
textDecoration: "line-through",
order: upMd ? 1 : 2,
}}
>
{currencyFormatter.format(cart.priceBeforeDiscounts / 100)}
</Typography>
<Typography
variant="p1"
sx={{
fontWeight: 500,
fontSize: "26px",
lineHeight: "31px",
order: upMd ? 2 : 1,
mr: "20px",
}}
>
{currencyFormatter.format(cart.priceAfterDiscounts / 100)}
</Typography>
</Box>
</Badge>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
mt: "25px",
gap: "15px",
}}
>
{notEnoughMoneyAmount > 0 && (
<Alert severity="error" variant="filled">
Не хватает {currencyFormatter.format(notEnoughMoneyAmount / 100)}
</Alert>
)}
<Button
disabled={cart.priceAfterDiscounts === 0}
variant="pena-contained-dark"
onClick={() => (notEnoughMoneyAmount === 0 ? !loading && handlePayClick() : handleReplenishWallet())}
sx={{ display: "block" }}
>
{loading ? <Loader size={24} /> : notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"}
</Button>
</Box>
</Box>
</Box>
</Box>
</SectionWrapper>
</Drawer>
</Box>
);
}
export default withErrorBoundary(Drawers, {
fallback: (
<Box
sx={{
display: "flex",
alignItems: "center",
}}
>
<ErrorOutlineIcon color="error" />
</Box>
),
onError: handleComponentError,
fallback: (
<Box
sx={{
display: "flex",
alignItems: "center",
}}
>
<ErrorOutlineIcon color="error" />
</Box>
),
onError: handleComponentError,
});

@ -10,58 +10,85 @@ import {
useMediaQuery,
useTheme,
} from "@mui/material";
import { TicketMessage } from "@frontend/kitui";
import {
addOrUpdateUnauthMessages,
useUnauthTicketStore,
incrementUnauthMessageApiPage,
setUnauthIsPreventAutoscroll,
setUnauthSessionData,
setIsMessageSending,
setUnauthTicketMessageFetchState,
} from "@root/stores/unauthTicket";
TicketMessage,
makeRequest,
useTicketsFetcher,
useTicketMessages,
getMessageFromFetchError,
useSSESubscription,
createTicket,
} from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon";
import ArrowLeft from "@root/assets/Icons/arrowLeft";
import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui";
import {
useTicketMessages,
getMessageFromFetchError,
useSSESubscription,
useEventListener,
createTicket,
} from "@frontend/kitui";
import { sendTicketMessage, shownMessage } from "@root/api/ticket";
import { useSSETab } from "@root/utils/hooks/useSSETab";
import {
checkAcceptableMediaType,
MAX_FILE_SIZE,
ACCEPT_SEND_MEDIA_TYPES_MAP,
} from "@utils/checkAcceptableMediaType";
import {
useTicketStore,
setTicketData,
addOrUpdateUnauthMessages,
setUnauthTicketMessageFetchState,
setUnauthIsPreventAutoscroll,
incrementUnauthMessage,
setIsMessageSending,
} from "@root/stores/tickets";
import { useUserStore } from "@root/stores/user";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import ChatDocument from "./ChatDocument";
import ChatImage from "./ChatImage";
import ChatVideo from "./ChatVideo";
import type { WheelEvent, TouchEvent } from "react";
type ModalWarningType =
| "errorType"
| "errorSize"
| "picture"
| "video"
| "audio"
| "document"
| null;
interface Props {
open: boolean;
sx?: SxProps<Theme>;
onclickArrow?: () => void;
}
export default function Chat({ open = false, sx }: Props) {
export default function Chat({ open = false, onclickArrow, sx }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(800));
const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore((state) => state.sessionData);
const messages = useUnauthTicketStore((state) => state.messages);
const messageApiPage = useUnauthTicketStore((state) => state.apiPage);
const messagesPerPage = useUnauthTicketStore(
(state) => state.messagesPerPage
);
const isMessageSending = useUnauthTicketStore(
(state) => state.isMessageSending
);
const isPreventAutoscroll = useUnauthTicketStore(
(state) => state.isPreventAutoscroll
);
const lastMessageId = useUnauthTicketStore((state) => state.lastMessageId);
const fetchState = useUnauthTicketStore(
(state) => state.unauthTicketMessageFetchState
);
const [disableFileButton, setDisableFileButton] = useState(false);
const [modalWarningType, setModalWarningType] =
useState<ModalWarningType>(null);
const chatBoxRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const user = useUserStore((state) => state.user?._id);
const ticket = useTicketStore(
(state) => state[user ? "authData" : "unauthData"]
);
const {
messages,
sessionData,
isMessageSending,
isPreventAutoscroll,
lastMessageId,
messagesPerPage,
unauthTicketMessageFetchState: fetchState,
apiPage: messageApiPage,
} = ticket;
const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"ticket",
addOrUpdateUnauthMessages
@ -74,8 +101,10 @@ export default function Chat({ open = false, sx }: Props) {
messagesPerPage,
messageApiPage,
onSuccess: useCallback((messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1)
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) {
chatBoxRef.current.scrollTop = 1;
}
addOrUpdateUnauthMessages(messages);
}, []),
onError: useCallback((error: Error) => {
@ -92,6 +121,7 @@ export default function Chat({ open = false, sx }: Props) {
`/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: (ticketMessages) => {
updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages);
},
onDisconnect: useCallback(() => {
@ -100,6 +130,34 @@ export default function Chat({ open = false, sx }: Props) {
marker: "ticket",
});
useTicketsFetcher({
url: process.env.REACT_APP_DOMAIN + "/heruvym/getTickets",
ticketsPerPage: 10,
ticketApiPage: 0,
onSuccess: (result) => {
if (result.data?.length) {
const currentTicket = result.data.find(
({ origin }) => !origin.includes("/support")
);
if (!currentTicket) {
return;
}
setTicketData({
ticketId: currentTicket.id,
sessionId: currentTicket.sess,
});
}
},
onError: (error: Error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
},
onFetchStateChange: () => {},
enabled: Boolean(user),
});
const throttledScrollHandler = useMemo(
() =>
throttle(() => {
@ -114,14 +172,12 @@ export default function Chat({ open = false, sx }: Props) {
if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage();
incrementUnauthMessage();
}
}, 200),
[fetchState]
);
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect(
function scrollOnNewMessage() {
if (!chatBoxRef.current) return;
@ -131,7 +187,6 @@ export default function Chat({ open = false, sx }: Props) {
scrollToBottom();
}, 50);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[lastMessageId]
);
@ -146,10 +201,18 @@ export default function Chat({ open = false, sx }: Props) {
}
}, [open, messages]);
const loadNewMessages = (
event: WheelEvent<HTMLDivElement> | TouchEvent<HTMLDivElement>
) => {
event.stopPropagation();
throttledScrollHandler();
};
async function handleSendMessage() {
if (!messageField || isMessageSending) return;
if (!sessionData) {
if (!sessionData?.ticketId) {
setIsMessageSending(true);
createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
@ -157,10 +220,10 @@ export default function Chat({ open = false, sx }: Props) {
Title: "Unauth title",
Message: messageField,
},
useToken: false,
useToken: Boolean(user),
})
.then((response) => {
setUnauthSessionData({
setTicketData({
ticketId: response.Ticket,
sessionId: response.sess,
});
@ -210,6 +273,66 @@ export default function Chat({ open = false, sx }: Props) {
}
};
const sendFile = async (file: File) => {
if (file === undefined) return true;
console.log("тут ошибка", modalWarningType);
let data;
if (!ticket.sessionData?.ticketId) {
try {
data = await createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
body: {
Title: "Unauth title",
Message: "",
},
useToken: Boolean(user),
});
setTicketData({
ticketId: data.Ticket,
sessionId: data.sess,
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
setIsMessageSending(false);
}
const ticketId = ticket.sessionData?.ticketId || data?.Ticket;
if (ticketId !== undefined) {
if (file.size > MAX_FILE_SIZE) return setModalWarningType("errorSize");
try {
const body = new FormData();
body.append(file.name, file);
body.append("ticket", ticketId);
await makeRequest({
url: process.env.REACT_APP_DOMAIN + "/heruvym/sendFiles",
body: body,
method: "POST",
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
return true;
}
};
const sendFileHC = async (file: File) => {
console.log(file);
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
console.log(disableFileButton);
};
return (
<>
{open && (
@ -217,7 +340,9 @@ export default function Chat({ open = false, sx }: Props) {
sx={{
display: "flex",
flexDirection: "column",
height: "clamp(250px, calc(100vh - 90px), 600px)",
height: isMobile
? "100%"
: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE",
borderRadius: "8px",
...sx,
@ -233,6 +358,11 @@ export default function Chat({ open = false, sx }: Props) {
filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}}
>
{isMobile && (
<IconButton onClick={onclickArrow}>
<ArrowLeft color="white" />
</IconButton>
)}
<UserCircleIcon />
<Box
sx={{
@ -263,6 +393,8 @@ export default function Chat({ open = false, sx }: Props) {
}}
>
<Box
onWheel={loadNewMessages}
onTouchMove={loadNewMessages}
ref={chatBoxRef}
sx={{
display: "flex",
@ -276,16 +408,87 @@ export default function Chat({ open = false, sx }: Props) {
flexGrow: 1,
}}
>
{sessionData &&
messages.map((message) => (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={sessionData.sessionId === message.user_id}
/>
))}
{ticket.sessionData?.ticketId &&
messages.map((message) => {
const isFileVideo = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileImage = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileDocument = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.document.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
if (message.files.length > 0 && isFileImage()) {
return (
<ChatImage
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileVideo()) {
return (
<ChatVideo
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
if (message.files.length > 0 && isFileDocument()) {
return (
<ChatDocument
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
}
return (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={
(ticket.sessionData?.sessionId || user) ===
message.user_id
}
/>
);
})}
</Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase
@ -314,6 +517,26 @@ export default function Chat({ open = false, sx }: Props) {
onChange={(e) => setMessageField(e.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
disabled={disableFileButton}
onClick={() => {
console.log(disableFileButton);
if (!disableFileButton) fileInputRef.current?.click();
}}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={({ target }) => {
if (target.files?.[0]) {
sendFileHC(target.files?.[0]);
}
}}
style={{ display: "none" }}
type="file"
/>
<IconButton
disabled={isMessageSending}
onClick={handleSendMessage}

@ -0,0 +1,113 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { isDateToday } from "../../utils/date";
import Download from "@root/assets/Icons/download";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatDocument({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : theme.palette.gray.main}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Link
download=""
href={`https://storage.yandexcloud.net/pair/${file}`}
style={{
color: "#7E2AEA",
display: "flex",
gap: "10px",
}}
>
<Download color={theme.palette.purple.main} />
</Link>
</Box>
</Box>
);
}

@ -0,0 +1,119 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { isDateToday } from "../../utils/date";
import { useNavigate } from "react-router-dom";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : "#434657"}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<ButtonBase target="_blank" href={`/image/${file}`}>
<Box
component="img"
sx={{
height: "217px",
width: "217px",
}}
src={`https://storage.yandexcloud.net/pair/${file}`}
/>
</ButtonBase>
</Box>
</Box>
);
}

@ -0,0 +1,125 @@
import {
Box,
ButtonBase,
Link,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { isDateToday } from "../../utils/date";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
interface Props {
unAuthenticated?: boolean;
isSelf: boolean;
file: string;
createdAt: string;
}
export default function ChatImage({
unAuthenticated = false,
isSelf,
file,
createdAt,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
useEffect(() => {
() => console.log("delete");
});
const messageBackgroundColor = isSelf
? "white"
: unAuthenticated
? "#EFF0F5"
: "#434657";
const date = new Date(createdAt);
const today = isDateToday(date);
const time = date.toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
...(!today && {
year: "2-digit",
month: "2-digit",
day: "2-digit",
}),
});
return (
<Box
sx={{
display: "flex",
gap: "9px",
padding: isSelf ? "0 8px 0 0" : "0 0 0 8px",
}}
>
<Typography
sx={{
alignSelf: "end",
fontWeight: 400,
fontSize: "14px",
lineHeight: "17px",
order: isSelf ? 1 : 2,
margin: isSelf ? "0 0 0 auto" : "0 auto 0 0",
color: "#434657",
mb: "-4px",
whiteSpace: "nowrap",
}}
>
{time}
</Typography>
<Box
sx={{
backgroundColor: messageBackgroundColor,
border: unAuthenticated
? "1px solid #E3E3E3"
: `1px solid ${"#434657"}`,
order: isSelf ? 2 : 1,
p: upMd ? "18px" : "12px",
borderRadius: "8px",
color: isSelf || unAuthenticated ? "#434657" : "white",
position: "relative",
maxWidth: `calc(100% - ${today ? 45 : 110}px)`,
overflowWrap: "break-word",
}}
>
<svg
style={{
position: "absolute",
top: "-1px",
right: isSelf ? "-8px" : undefined,
left: isSelf ? undefined : "-8px",
transform: isSelf ? undefined : "scale(-1, 1)",
}}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="8"
viewBox="0 0 16 8"
fill="none"
>
<path
d="M0.5 0.5L15.5 0.500007
C10 0.500006 7.5 8 7.5 7.5H7.5H0.5V0.5Z"
fill={messageBackgroundColor}
stroke={unAuthenticated ? "#E3E3E3" : "#434657"}
/>
<rect y="1" width="8" height="8" fill={messageBackgroundColor} />
</svg>
<Box
component="video"
sx={{
pointerEvents: "auto",
height: "217px",
width: "auto",
minWidth: "217px",
}}
controls
>
<source src={`https://storage.yandexcloud.net/pair/${file}`} />
</Box>
</Box>
</Box>
);
}

@ -1,15 +1,51 @@
import { useState } from "react";
import { Box, Fab, Typography, Badge, useTheme } from "@mui/material";
import { useState, useEffect, forwardRef } from "react";
import {
Box,
Fab,
Typography,
Badge,
Dialog,
Slide,
useTheme,
useMediaQuery,
} from "@mui/material";
import CircleDoubleDown from "./CircleDoubleDownIcon";
import Chat from "./Chat";
import { useUnauthTicketStore } from "@root/stores/unauthTicket";
import { useUserStore } from "@root/stores/user";
import { useTicketStore } from "@root/stores/tickets";
import type { ReactNode } from "react";
import type { TransitionProps } from "@mui/material/transitions";
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: ReactNode;
},
ref: React.Ref<unknown>
) {
console.log(props.children);
return (
<Slide direction="up" ref={ref} {...props}>
{props.children ? (
<Box sx={{ height: "100%" }}>{props.children}</Box>
) : (
<Box />
)}
</Slide>
);
});
export default function FloatingSupportChat() {
const [monitorType, setMonitorType] = useState<"desktop" | "mobile" | "">("");
const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
const theme = useTheme();
const { messages } = useUnauthTicketStore((state) => state);
const isMobile = useMediaQuery(theme.breakpoints.down(800));
const user = useUserStore((state) => state.user?._id);
const { messages } = useTicketStore(
(state) => state[user ? "authData" : "unauthData"]
);
const animation = {
"@keyframes runningStripe": {
@ -35,6 +71,25 @@ export default function FloatingSupportChat() {
},
},
};
useEffect(() => {
const onResize = () => {
if (document.fullscreenElement) {
setMonitorType(isMobile ? "mobile" : "desktop");
return;
}
setMonitorType("");
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [isMobile]);
return (
<Box
sx={{
@ -49,10 +104,20 @@ export default function FloatingSupportChat() {
}}
>
<Chat
open={isChatOpened}
open={isChatOpened && (monitorType === "desktop" || !isMobile)}
sx={{ alignSelf: "start", width: "clamp(200px, 100%, 400px)" }}
/>
<Dialog
fullScreen
open={isChatOpened && (monitorType === "mobile" || isMobile)}
onClose={() => setIsChatOpened(false)}
TransitionComponent={Transition}
>
<Chat
open={isChatOpened && (monitorType === "mobile" || isMobile)}
onclickArrow={() => setIsChatOpened(false)}
/>
</Dialog>
<Fab
disableRipple
sx={{

@ -228,7 +228,7 @@ export default function DialogMenu({ open, handleClose }: DialogMenuProps) {
variant="pena-contained-dark"
sx={{ px: "30px", ml: "40px", width: "245px", mt: "50px" }}
>
Регистрация / Войти
{user ? "Личный кабинет" : "Регистрация / Войти"}
</Button>
) : (
<Box

@ -21,7 +21,7 @@ export default function NavbarCollapsed({ isLoggedIn }: Props) {
component="nav"
maxWidth="lg"
outerContainerSx={{
zIndex: "111111111111",
zIndex: "999",
position: "fixed",
top: "0",
backgroundColor: theme.palette.bg.main,

@ -7,6 +7,7 @@ import Wallet from "./pages/Wallet"
import Payment from "./pages/Payment/Payment"
import QuizPayment from "./pages/QuizPayment/QuizPayment"
import Support from "./pages/Support/Support"
import ChatImageNewWindow from "./pages/Support/ChatImageNewWindow"
import AccountSettings from "./pages/AccountSettings/AccountSettings"
import Landing from "./pages/Landing/Landing"
import Tariffs from "./pages/Tariffs/Tariffs"
@ -98,6 +99,7 @@ const App = () => {
<Route path="/changepwd" element={<Navigate to="/" replace state={{ redirectTo: window.location.pathname + window.location.search }} />} />
<Route path="/changepwd/expired" element={<Navigate to="/" replace state={{ redirectTo: "/changepwd/expired" }} />} />
<Route path={"/image/:srcImage"} element={<ChatImageNewWindow />} />
<Route element={<PrivateRoute />}>
<Route element={<ProtectedLayout />}>
<Route path="/tariffs" element={<Tariffs />} />

@ -1,5 +1,5 @@
export interface SendPaymentRequest {
type: "bankCard";
type: string;
currency: string;
amount: number;
bankCard: {

@ -37,6 +37,7 @@ export default function AccordionWrapper({ content, last, first, createdAt, onCl
content[0].Value[0].forEach((item) => {
valuesByKey[item.Key] = item.Value
})
console.log("Я врапер")
console.log(content)
console.log(content[0])
console.log(content[0].Value)

@ -0,0 +1,233 @@
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import CustomAccordion from "@components/CustomAccordion";
import File from "@components/icons/File";
import { getDeclension } from "@utils/declension";
import { enqueueSnackbar } from "notistack";
import { addTariffToCart } from "@root/stores/user";
import { Tariff } from "@frontend/kitui";
import { currencyFormatter } from "@root/utils/currencyFormatter";
export type History = {
title: string;
date: string;
info: string;
description: string;
payMethod?: string;
expired?: boolean;
};
interface AccordionWrapperProps {
tariff: Tariff;
price: number;
last?: boolean;
first?: boolean;
createdAt: string;
}
export default function AccordionWrapper2({ tariff, price, last, first, createdAt }: AccordionWrapperProps) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const isTablet = useMediaQuery(theme.breakpoints.down(900));
const isMobile = useMediaQuery(theme.breakpoints.down(560));
async function handleTariffItemClick(tariffId: string) {
const { patchCartError } = await addTariffToCart(tariffId);
if (patchCartError) {
enqueueSnackbar(patchCartError);
} else {
enqueueSnackbar("Тариф добавлен в корзину");
}
}
return (
<Box
sx={{
borderRadius: "12px",
}}
>
<CustomAccordion
last={last}
first={first}
divide
text={tariff.privileges.map(privilege => (
`${privilege.description} - ${privilege.serviceKey} ${getDeclension(Number(privilege.serviceKey), privilege.value)}`
))}
header={
<>
<Box
sx={{
width: "100%",
height: upMd ? "72px" : undefined,
padding: "20px 20px 20px 0",
display: "flex",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
gap: "20px",
alignItems: upSm ? "center" : undefined,
flexDirection: upSm ? undefined : "column",
}}
>
<Box
sx={{
display: "flex",
alignItems: upSm ? "center" : undefined,
justifyContent: "space-between",
flexDirection: upSm ? undefined : "column",
gap: upMd ? "51px" : "10px",
}}
>
<Typography
sx={{
width: "110px",
fontSize: upMd ? "20px" : "18px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: /* valuesByKey.expired */ false ? theme.palette.text.disabled : theme.palette.text.secondary,
px: 0,
whiteSpace: "nowrap",
}}
>
{createdAt}
</Typography>
<Typography
title={tariff.isCustom ? "Мой тариф" : tariff.name}
sx={{
fontSize: upMd ? "18px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: /* valuesByKey.expired */ false ? theme.palette.text.disabled : theme.palette.gray.dark,
px: 0,
width: "200px",
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>
{tariff.isCustom ? "Мой тариф" : tariff.name}
</Typography>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexFlow: "1",
flexBasis: "60%",
}}
>
<Box display="flex" width="100%" justifyContent="space-between">
{/* <Typography
sx={{
display: upMd ? undefined : "none",
fontSize: upMd ? "18px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 400,
color: valuesByKey.expired ? theme.palette.text.disabled : theme.palette.gray.dark,
px: 0,
}}
>
{valuesByKey.payMethod && <Typography
sx={{
maxWidth: "300px",
width: "300px",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>Способ оплаты: {valuesByKey.payMethod}</Typography>}
</Typography> */}
<Box
sx={{
display: "flex",
height: "100%",
alignItems: "center",
gap: upSm ? "111px" : "17px",
width: "100%",
maxWidth: isTablet ? null : "160px",
}}
>
<Typography
sx={{
marginLeft: isTablet ? (isMobile ? null : "auto") : null,
color: /* valuesByKey.expired */ false ? theme.palette.text.disabled : theme.palette.gray.dark,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
textAlign: "left",
}}
>
{currencyFormatter.format(price)}
</Typography>
</Box>
</Box>
{!isMobile &&
<>
{/* <IconButton onClick={onClickMail}>
<EmailIcon fontSize={"large"}/>
</IconButton> */}
<IconButton
title="Добавить в корзину тариф"
onClick={(e) => {
e.stopPropagation();
handleTariffItemClick(tariff._id);
}}
sx={{
ml: "20px",
bgcolor: "#EEE4FC",
stroke: "#7E2AEA",
borderRadius: 2,
"&:hover": {
bgcolor: "#7E2AEA",
stroke: "white",
},
"&:active": {
bgcolor: "black",
stroke: "white",
}
}}
>
<File></File>
</IconButton>
</>
}
</Box>
</Box>
{isMobile &&
<>
{/* <IconButton onClick={onClickMail}>
<EmailIcon fontSize={"large"}/>
</IconButton> */}
<IconButton
title="Добавить в корзину тариф"
onClick={(e) => {
e.stopPropagation();
handleTariffItemClick(tariff._id);
}}
sx={{
mr: "10px",
bgcolor: "#EEE4FC",
stroke: "#7E2AEA",
borderRadius: 2,
"&:hover": {
bgcolor: "#7E2AEA",
stroke: "white",
},
"&:active": {
bgcolor: "black",
stroke: "white",
}
}}
>
<File></File>
</IconButton>
</>
}
</>
}
/>
</Box>
);
}

@ -1,121 +1,150 @@
import { useEffect, useState } from "react"
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material"
import ArrowBackIcon from "@mui/icons-material/ArrowBack"
import { useEffect, useState } from "react";
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SectionWrapper from "@root/components/SectionWrapper"
import { Select } from "@root/components/Select"
import { Tabs } from "@root/components/Tabs"
import SectionWrapper from "@root/components/SectionWrapper";
import { Select } from "@root/components/Select";
import { Tabs } from "@root/components/Tabs";
import AccordionWrapper from "./AccordionWrapper"
import { HISTORY } from "./historyMocks"
import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker"
import { useHistoryData } from "@root/utils/hooks/useHistoryData"
import { isArray } from "cypress/types/lodash"
import { ErrorBoundary } from "react-error-boundary"
import { handleComponentError } from "@root/utils/handleComponentError"
import AccordionWrapper from "./AccordionWrapper";
import { HISTORY } from "./historyMocks";
import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker";
import { useHistoryData } from "@root/utils/hooks/useHistoryData";
import { isArray } from "cypress/types/lodash";
import { ErrorBoundary } from "react-error-boundary";
import { handleComponentError } from "@root/utils/handleComponentError";
import { useHistoryStore } from "@root/stores/history";
import EmailIcon from '@mui/icons-material/Email';
import {enqueueSnackbar} from "notistack"
import { makeRequest } from "@frontend/kitui"
import { enqueueSnackbar } from "notistack";
import { makeRequest } from "@frontend/kitui";
import { HistoryRecord, HistoryRecord2 } from "@root/api/history";
import AccordionWrapper2 from "./AccordionWrapper2";
const subPages = ["Платежи"]
const subPages = ["Платежи"];
// const subPages = ["Платежи", "Покупки тарифов", "Окончания тарифов"]
export default function History() {
const [selectedItem, setSelectedItem] = useState<number>(0)
const [selectedItem, setSelectedItem] = useState<number>(0);
const theme = useTheme()
const upMd = useMediaQuery(theme.breakpoints.up("md"))
const isMobile = useMediaQuery(theme.breakpoints.down(600))
const isTablet = useMediaQuery(theme.breakpoints.down(1000))
const historyData = useHistoryStore(state => state.history)
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const historyData = useHistoryStore(state => state.history);
const handleCustomBackNavigation = useHistoryTracker();
const handleCustomBackNavigation = useHistoryTracker()
const extractDateFromString = (tariffName: string) => {
const dateMatch = tariffName.match(/\d{4}-\d{2}-\d{2}/);
return dateMatch ? dateMatch[0] : "";
};
const extractDateFromString = (tariffName: string) => {
const dateMatch = tariffName.match(/\d{4}-\d{2}-\d{2}/)
return dateMatch ? dateMatch[0] : ""
}
async function handleHistoryResponse(tariffId: string) {
try {
await makeRequest(
{
url: process.env.REACT_APP_DOMAIN + `/customer/sendReport/${tariffId}`,
method: "POST",
}
);
enqueueSnackbar("Запрос отправлен");
} catch (e) {
enqueueSnackbar("извините, произошла ошибка");
}
}
async function handleHistoryResponse(tariffId: string) {
try {
await makeRequest (
{
url: process.env.REACT_APP_DOMAIN + `/customer/sendReport/${tariffId}`,
method: "POST",
}
)
enqueueSnackbar("Запрос отправлен")
} catch (e) {
enqueueSnackbar("извините, произошла ошибка")
}
}
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: upMd ? "25px" : "20px",
mb: upMd ? "70px" : "37px",
px: isTablet ? (isTablet ? "18px" : "40px") : "20px",
}}
>
<Box
sx={{
mt: "20px",
mb: isTablet ? "38px" : "20px",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile && (
<IconButton onClick={handleCustomBackNavigation} sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography
sx={{
fontSize: isMobile ? "24px" : "36px",
fontWeight: "500",
}}
>
История
</Typography>
</Box>
{isMobile ? (
<Select items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
) : (
<Tabs items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
)}
<ErrorBoundary
fallback={
<Typography mt="8px">Ошибка загрузки истории</Typography>
}
onError={handleComponentError}
>
{historyData?.length === 0 && <Typography textAlign="center" >Нет данных</Typography>}
{historyData?.filter((e) => {
e.createdAt = extractDateFromString(e.createdAt)
return(!e.isDeleted && e.key === "payCart" && Array.isArray(e.rawDetails[0].Value)
)})
.map(( e, index) => {
return (
<Box key={index} sx={{ mt: index === 0 ? "27px" : "0px" }}>
<AccordionWrapper
first={index === 0}
last={index === historyData?.length - 1}
content={e.rawDetails}
key={e.id}
createdAt={e.createdAt}
onClickMail={(event: any)=>{
event.stopPropagation()
handleHistoryResponse(e.id)
}}
/>
</Box>
)})}
</ErrorBoundary>
</SectionWrapper>
)
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: upMd ? "25px" : "20px",
mb: upMd ? "70px" : "37px",
px: isTablet ? (isTablet ? "18px" : "40px") : "20px",
}}
>
<Box
sx={{
mt: "20px",
mb: isTablet ? "38px" : "20px",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile && (
<IconButton onClick={handleCustomBackNavigation} sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography
sx={{
fontSize: isMobile ? "24px" : "36px",
fontWeight: "500",
}}
>
История
</Typography>
</Box>
{isMobile ? (
<Select items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
) : (
<Tabs items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
)}
<ErrorBoundary
fallback={
<Typography mt="8px">Ошибка загрузки истории</Typography>
}
onError={handleComponentError}
>
{historyData?.length === 0 && <Typography textAlign="center">Нет данных</Typography>}
{/* Для ненормального rawDetails */}
{historyData?.filter((e): e is HistoryRecord => {
e.createdAt = extractDateFromString(e.createdAt);
return (
!e.isDeleted
&& e.key === "payCart"
&& Array.isArray(e.rawDetails)
&& Array.isArray(e.rawDetails[0].Value)
);
}).map((e, index) => {
return (
<Box key={index} sx={{ mt: index === 0 ? "27px" : "0px" }}>
<AccordionWrapper
first={index === 0}
last={index === historyData?.length - 1}
content={(e as HistoryRecord).rawDetails}
key={e.id}
createdAt={e.createdAt}
onClickMail={(event: any) => {
event.stopPropagation();
handleHistoryResponse(e.id);
}}
/>
</Box>
);
})}
{/* Для нормального rawDetails */}
{historyData?.filter((e): e is HistoryRecord2 => {
e.createdAt = extractDateFromString(e.createdAt);
return (
!e.isDeleted
&& e.key === "payCart"
&& !Array.isArray(e.rawDetails)
&& !!e.rawDetails.tariffs[0]
);
}).map((e, index) => {
return (
<Box key={index} sx={{ mt: index === 0 ? "27px" : "0px" }}>
<AccordionWrapper2
key={e.id}
first={index === 0}
last={index === historyData?.length - 1}
createdAt={e.createdAt}
tariff={e.rawDetails.tariffs[0]}
price={e.rawDetails.price}
/>
</Box>
);
})}
</ErrorBoundary>
</SectionWrapper>
);
}

@ -9,10 +9,10 @@ import {
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 umoneyLogo from "@root/assets/bank-logo/umaney.png";
import b2bLogo from "@root/assets/bank-logo/b2b.png";
import spbLogo from "@root/assets/bank-logo/spb.png";
import sberpayLogo from "@root/assets/bank-logo/sberpay.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";
@ -37,11 +37,11 @@ type PaymentMethod = {
};
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 },
{ label: "Тинькофф", name: "tinkoffBank", image: tinkoffLogo },
{ label: "СБП", name: "sbp", image: spbLogo },
{ label: "SberPay", name: "sberbank", image: sberpayLogo },
{ label: "B2B Сбербанк", name: "b2bSberbank", image: b2bLogo },
{ label: "ЮMoney", name: "yoomoney", image: umoneyLogo },
];
type PaymentMethodType = (typeof paymentMethods)[number]["name"];
@ -53,7 +53,7 @@ export default function Payment() {
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [selectedPaymentMethod, setSelectedPaymentMethod] =
useState<PaymentMethodType | null>("rspay");
useState<PaymentMethodType | null>("");
const [warnModalOpen, setWarnModalOpen] = useState<boolean>(false);
const [sorryModalOpen, setSorryModalOpen] = useState<boolean>(false);
const [paymentValueField, setPaymentValueField] = useState<string>("0");
@ -90,14 +90,56 @@ export default function Payment() {
}
if (Number(paymentValueField) === 0) {
enqueueSnackbar("Введите сумму")
return;
}
if (selectedPaymentMethod !== "rspay") {
const [sendPaymentResponse, sendPaymentError] = await sendPayment({
fromSquiz,
fromSquiz,
body: {
type: selectedPaymentMethod,
amount: Number(paymentValueField) * 100,
currency: "RUB",
bankCard: {
number: "RUB",
expiryYear: "2021",
expiryMonth: "05",
csc: "05",
cardholder: "IVAN IVANOV",
},
phoneNumber: "79000000000",
login: "login_test",
returnUrl: window.location.origin + "/wallet",
},
});
if (selectedPaymentMethod === "rspay") {
if (verificationStatus !== VerificationStatus.VERIFICATED) {
setWarnModalOpen(true);
return;
}
console.log(paymentValueField)
if (Number(paymentValueField) < 900){
enqueueSnackbar("Минимальная сумма 900р")
return;
}
const sendRSPaymentError = await sendRSPayment(Number(paymentValueField));
if (sendRSPaymentError) {
return enqueueSnackbar(sendRSPaymentError);
}
enqueueSnackbar(
"Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
);
navigate("/settings");
}
if (sendPaymentError) {
return enqueueSnackbar(sendPaymentError);
}
@ -107,6 +149,31 @@ export default function Payment() {
}
return;
} else {
if (verificationStatus !== VerificationStatus.VERIFICATED) {
setWarnModalOpen(true);
return;
}
console.log(paymentValueField)
if (Number(paymentValueField) < 900){
enqueueSnackbar("Минимальная сумма 900р")
return;
}
const sendRSPaymentError = await sendRSPayment(Number(paymentValueField));
if (sendRSPaymentError) {
return enqueueSnackbar(sendRSPaymentError);
}
enqueueSnackbar(
"Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
);
navigate("/settings");
}
}
@ -167,46 +234,22 @@ export default function Payment() {
>
{paymentMethods.map(({ name, label, image, unpopular = false }) => (
<PaymentMethodCard
isSelected={false}
isSelected={selectedPaymentMethod === name}
key={name}
label={label}
image={image}
onClick={() => {
setSorryModalOpen(true)
// setSelectedPaymentMethod(name)
setSelectedPaymentMethod(name)
}}
unpopular={true}
unpopular={false}
/>
))}
<PaymentMethodCard
isSelected={false}
isSelected={selectedPaymentMethod === "rspay"}
label={"Расчётный счёт"}
image={rsPayLogo}
onClick={async() => {
if (verificationStatus !== VerificationStatus.VERIFICATED) {
setWarnModalOpen(true);
return;
}
console.log(paymentValueField)
if (Number(paymentValueField) < 900){
enqueueSnackbar("Минимальная сумма 900р")
return;
}
const sendRSPaymentError = await sendRSPayment(Number(paymentValueField));
if (sendRSPaymentError) {
return enqueueSnackbar(sendRSPaymentError);
}
enqueueSnackbar(
"Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
);
navigate("/settings");
setSelectedPaymentMethod("rspay")
}}
unpopular={false}
/>
@ -262,49 +305,7 @@ export default function Payment() {
/>
)}
</Box>
<Button
variant="pena-outlined-light"
onClick={async () => {
if (verificationStatus !== VerificationStatus.VERIFICATED) {
setWarnModalOpen(true);
return;
}
console.log(paymentValueField)
if (Number(paymentValueField) < 900){
enqueueSnackbar("Минимальная сумма 900р")
return;
}
const sendRSPaymentError = await sendRSPayment(Number(paymentValueField));
if (sendRSPaymentError) {
return enqueueSnackbar(sendRSPaymentError);
}
enqueueSnackbar(
"Cпасибо за заявку, в течении 24 часов вам будет выставлен счёт для оплаты услуг."
);
navigate("/settings");
}}
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>
{/* {paymentLink ? (
{paymentLink ? (
<Button
variant="pena-outlined-light"
component="a"
@ -340,7 +341,7 @@ export default function Payment() {
>
Выбрать
</Button>
)} */}
)}
</Box>
</Box>
<WarnModal open={warnModalOpen} setOpen={setWarnModalOpen} />

@ -0,0 +1,20 @@
import { Box } from "@mui/material";
import { useLocation } from "react-router-dom";
export default function ChatImageNewWindow() {
const location = useLocation();
console.log(location);
const srcImage = location.pathname.split("image/")[1];
return (
<>
<Box
component="img"
sx={{
maxHeight: "100vh",
maxWidth: "100vw",
}}
src={`https://storage.yandexcloud.net/pair/${srcImage}`}
/>
</>
);
}

@ -14,7 +14,7 @@ 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 { makeRequest, throttle, useToken } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets";
import {
@ -38,8 +38,18 @@ 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";
import {
checkAcceptableMediaType,
MAX_FILE_SIZE,
ACCEPT_SEND_MEDIA_TYPES_MAP,
} from "@utils/checkAcceptableMediaType";
import AttachFileIcon from "@mui/icons-material/AttachFile";
import ChatDocument from "@components/FloatingSupportChat/ChatDocument";
import ChatImage from "@components/FloatingSupportChat/ChatImage";
import ChatVideo from "@components/FloatingSupportChat/ChatVideo";
function SupportChat() {
console.log("сапортчат отрисовался");
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.up(460));
@ -52,6 +62,8 @@ function SupportChat() {
const isPreventAutoscroll = useMessageStore(
(state) => state.isPreventAutoscroll
);
const [disableFileButton, setDisableFileButton] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const token = useToken();
const ticketId = useParams().ticketId;
const ticket = tickets.find((ticket) => ticket.id === ticketId);
@ -127,7 +139,6 @@ function SupportChat() {
}, 50);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastMessageId]
);
@ -177,6 +188,45 @@ function SupportChat() {
minute: "2-digit",
});
const sendFile = async (file: File) => {
if (file === undefined) return true;
if (ticketId) {
if (file.size > MAX_FILE_SIZE) {
return;
}
try {
const body = new FormData();
body.append(file.name, file);
body.append("ticket", ticketId);
await makeRequest({
url: process.env.REACT_APP_DOMAIN + "/heruvym/sendFiles",
body: body,
method: "POST",
});
} catch (error: any) {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
}
return true;
}
};
const sendFileHC = async (file: File) => {
console.log(file);
const check = checkAcceptableMediaType(file);
if (check.length > 0) {
enqueueSnackbar(check);
return;
}
setDisableFileButton(true);
await sendFile(file);
setDisableFileButton(false);
console.log(disableFileButton);
};
return (
<Box
sx={{
@ -262,14 +312,87 @@ function SupportChat() {
}}
>
{ticket &&
messages.map((message) => (
<ChatMessage
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
))}
messages.map((message) => {
const isFileVideo = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileImage = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.picture.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
const isFileDocument = () => {
if (message.files) {
return ACCEPT_SEND_MEDIA_TYPES_MAP.document.some(
(fileType) =>
message.files[0].toLowerCase().endsWith(fileType)
);
}
};
if (
message.files !== null &&
message.files.length > 0 &&
isFileImage()
) {
return (
<ChatImage
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
);
}
if (
message.files !== null &&
message.files.length > 0 &&
isFileVideo()
) {
return (
<ChatVideo
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
);
}
if (
message.files !== null &&
message.files.length > 0 &&
isFileDocument()
) {
return (
<ChatDocument
unAuthenticated
key={message.id}
file={message.files[0]}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
);
}
return (
<ChatMessage
unAuthenticated
key={message.id}
text={message.message}
createdAt={message.created_at}
isSelf={ticket.user === message.user_id}
/>
);
})}
</Box>
</Box>
<FormControl>
@ -298,6 +421,27 @@ function SupportChat() {
endAdornment={
!upMd && (
<InputAdornment position="end">
<IconButton
sx={{ mr: "4px" }}
disabled={disableFileButton}
onClick={() => {
console.log(disableFileButton);
if (!disableFileButton) fileInputRef.current?.click();
}}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={({ target }) => {
if (target.files?.[0]) {
sendFileHC(target.files?.[0]);
}
}}
style={{ display: "none" }}
type="file"
/>
<IconButton
onClick={handleSendMessage}
sx={{
@ -318,6 +462,27 @@ function SupportChat() {
</Box>
{upMd && (
<Box sx={{ alignSelf: "end" }}>
<IconButton
sx={{ mr: "4px" }}
disabled={disableFileButton}
onClick={() => {
console.log(disableFileButton);
if (!disableFileButton) fileInputRef.current?.click();
}}
>
<AttachFileIcon />
</IconButton>
<input
ref={fileInputRef}
id="fileinput"
onChange={({ target }) => {
if (target.files?.[0]) {
sendFileHC(target.files?.[0]);
}
}}
style={{ display: "none" }}
type="file"
/>
<Button
variant="pena-contained-dark"
onClick={handleSendMessage}

@ -4,7 +4,6 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import NumberIcon from "@root/components/NumberIcon";
import { useDiscountStore } from "@root/stores/discounts";
import { useHistoryStore } from "@root/stores/history";
import { useTariffStore } from "@root/stores/tariffs";
import { addTariffToCart, useUserStore } from "@root/stores/user";
import { calcIndividualTariffPrices } from "@root/utils/calcTariffPrices";
@ -39,7 +38,6 @@ function TariffPage() {
const discounts = useDiscountStore((state) => state.discounts);
const purchasesAmount = useUserStore((state) => state.userAccount?.wallet.spent) ?? 0;
const isUserNko = useUserStore((state) => state.userAccount?.status) === "nko";
const historyData = useHistoryStore((state) => state.history);
const currentTariffs = useCartTariffs();
const handleCustomBackNavigation = usePrevLocation(location);
@ -70,20 +68,6 @@ function TariffPage() {
return false
});
const isCustomTariffs = tariffs.filter((tariff) => {
return (
tariff.privileges.map((p) => p.type).includes("day") === (unit === "time") && !tariff.isDeleted && tariff.isCustom
);
});
const tariffsFromHistory = tariffs.filter((tariff) => {
if (!historyData) return false;
const historyTariffIds = historyData.map((historyRecord) => (historyRecord.rawDetails[0] as any).Value[0][0].Value);
return historyTariffIds.includes(tariff._id);
});
const createTariffElements = (filteredTariffs: Tariff[], addFreeTariff = false) => {
console.log(filteredTariffs)
const tariffElements = filteredTariffs

@ -1,10 +1,10 @@
import { HistoryRecord } from "@root/api/history";
import { HistoryRecord, HistoryRecord2 } from "@root/api/history";
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type HistoryStore = {
history: HistoryRecord[] | null;
history: HistoryRecord[] | HistoryRecord2[] | null;
};
const initialState: HistoryStore = {

@ -1,46 +1,183 @@
import { FetchState, Ticket } from "@frontend/kitui"
import { create } from "zustand"
import { devtools } from "zustand/middleware"
import { FetchState, Ticket, TicketMessage } from "@frontend/kitui";
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { useUserStore } from "./user";
import { produce } from "immer";
type SessionData = {
ticketId: string;
sessionId: string;
};
interface AuthData {
sessionData: SessionData | null;
isMessageSending: boolean;
messages: TicketMessage[];
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
isPreventAutoscroll: boolean;
unauthTicketMessageFetchState: FetchState;
}
interface TicketStore {
ticketCount: number;
tickets: Ticket[];
apiPage: number;
ticketsPerPage: number;
ticketsFetchState: FetchState;
ticketCount: number;
tickets: Ticket[];
apiPage: number;
ticketsPerPage: number;
ticketsFetchState: FetchState;
authData: AuthData;
unauthData: AuthData;
}
const initAuthData = {
sessionData: null,
isMessageSending: false,
messages: [],
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
isPreventAutoscroll: false,
unauthTicketMessageFetchState: "idle" as FetchState,
};
const initialState: TicketStore = {
ticketCount: 0,
tickets: [],
apiPage: 0,
ticketsPerPage: 10,
ticketsFetchState: "idle",
}
ticketCount: 0,
tickets: [],
apiPage: 0,
ticketsPerPage: 10,
ticketsFetchState: "idle",
authData: initAuthData,
unauthData: initAuthData,
};
export const useTicketStore = create<TicketStore>()(
devtools(
(set, get) => initialState,
{
name: "Tickets"
}
)
)
persist(
devtools((set, get) => initialState, {
name: "Unauth tickets",
}),
{
version: 0,
name: "unauth-ticket",
storage: createJSONStorage(() => localStorage),
}
)
);
export const setTicketCount = (ticketCount: number) => useTicketStore.setState({ ticketCount })
export const setTicketCount = (ticketCount: number) =>
useTicketStore.setState({ ticketCount });
export const setTicketApiPage = (apiPage: number) => useTicketStore.setState({ apiPage: apiPage })
export const setTicketApiPage = (apiPage: number) =>
useTicketStore.setState({ apiPage: apiPage });
export const updateTickets = (receivedTickets: Ticket[]) => {
const state = useTicketStore.getState()
const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {};
const state = useTicketStore.getState();
const ticketIdToTicketMap: { [ticketId: string]: Ticket } = {};
[...state.tickets, ...receivedTickets].forEach(ticket => ticketIdToTicketMap[ticket.id] = ticket)
[...state.tickets, ...receivedTickets].forEach(
(ticket) => (ticketIdToTicketMap[ticket.id] = ticket)
);
useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) })
useTicketStore.setState({ tickets: Object.values(ticketIdToTicketMap) });
};
export const clearTickets = () => useTicketStore.setState({ ...initialState });
export const setTicketsFetchState = (ticketsFetchState: FetchState) =>
useTicketStore.setState({ ticketsFetchState });
export const setTicketData = (sessionData: SessionData) =>
updateTicket((ticket) => {
ticket.sessionData = sessionData;
});
export const updateTicket = <T extends AuthData>(
recipe: (ticket: AuthData) => void
) =>
setProducedState(
(state) => {
//В зависимости от авторизованности вызывается изменение разных объектов
if (Boolean(useUserStore.getState().userId)) {
recipe(state.authData);
} else {
recipe(state.unauthData);
}
},
{
type: "updateTicket",
recipe,
}
);
function setProducedState<A extends string | { type: unknown }>(
recipe: (state: TicketStore) => void,
action?: A
) {
useTicketStore.setState((state) => produce(state, recipe), false, action);
}
export const clearTickets = () => useTicketStore.setState({ ...initialState })
function filterMessageUncompleteness(messages: TicketMessage[]) {
return messages.filter(
(message) =>
"id" in message &&
"ticket_id" in message &&
"user_id" in message &&
"session_id" in message &&
"message" in message &&
"files" in message &&
"shown" in message &&
"request_screenshot" in message &&
"created_at" in message &&
((message.files !== null && message.files.length > 0) ||
message.message.length > 0)
);
}
export const setTicketsFetchState = (ticketsFetchState: FetchState) => useTicketStore.setState({ ticketsFetchState })
function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) {
const date1 = new Date(ticket1.created_at).getTime();
const date2 = new Date(ticket2.created_at).getTime();
return date1 - date2;
}
export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) =>
updateTicket((ticket) => {
const filtered = filterMessageUncompleteness(receivedMessages);
if (filtered.length === 0) return;
const messageIdToMessageMap: { [messageId: string]: TicketMessage } = {};
[...ticket.messages, ...filtered].forEach(
(message) => (messageIdToMessageMap[message.id] = message)
);
const sortedMessages = Object.values(messageIdToMessageMap).sort(
sortMessagesByTime
);
ticket.messages = sortedMessages;
ticket.lastMessageId = sortedMessages.at(-1)?.id;
});
export const setUnauthTicketMessageFetchState = (
unauthTicketMessageFetchState: FetchState
) =>
updateTicket((ticket) => {
ticket.unauthTicketMessageFetchState = unauthTicketMessageFetchState;
});
export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) =>
updateTicket((ticket) => {
ticket.isPreventAutoscroll = isPreventAutoscroll;
});
export const incrementUnauthMessage = () =>
updateTicket((ticket) => {
ticket.apiPage++;
});
export const setIsMessageSending = (
isMessageSending: AuthData["isMessageSending"]
) => {
updateTicket((ticket) => {
ticket.isMessageSending = isMessageSending;
});
};

@ -1,79 +0,0 @@
import { FetchState, TicketMessage } from "@frontend/kitui"
import { create } from "zustand"
import { createJSONStorage, devtools, persist } from "zustand/middleware"
interface UnauthTicketStore {
sessionData: {
ticketId: string;
sessionId: string;
} | null;
isMessageSending: boolean;
messages: TicketMessage[];
apiPage: number;
messagesPerPage: number;
lastMessageId: string | undefined;
isPreventAutoscroll: boolean;
unauthTicketMessageFetchState: FetchState;
}
export const useUnauthTicketStore = create<UnauthTicketStore>()(
persist(
devtools(
(set, get) => ({
sessionData: null,
isMessageSending: false,
messages: [],
apiPage: 0,
messagesPerPage: 10,
lastMessageId: undefined,
isPreventAutoscroll: false,
unauthTicketMessageFetchState: "idle",
}),
{
name: "Unauth tickets"
}
),
{
version: 0,
name: "unauth-ticket",
storage: createJSONStorage(() => localStorage),
partialize: state => ({
sessionData: state.sessionData,
})
}
)
)
export const setUnauthSessionData = (sessionData: UnauthTicketStore["sessionData"]) => useUnauthTicketStore.setState({ sessionData })
export const setIsMessageSending = (isMessageSending: UnauthTicketStore["isMessageSending"]) => useUnauthTicketStore.setState({ isMessageSending })
export const addOrUpdateUnauthMessages = (receivedMessages: TicketMessage[]) => {
const state = useUnauthTicketStore.getState()
const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {};
[...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message)
const sortedMessages = Object.values(messageIdToMessageMap).sort(sortMessagesByTime)
useUnauthTicketStore.setState({
messages: sortedMessages,
lastMessageId: sortedMessages.at(-1)?.id,
})
}
export const incrementUnauthMessageApiPage = () => {
const state = useUnauthTicketStore.getState()
useUnauthTicketStore.setState({ apiPage: state.apiPage + 1 })
}
export const setUnauthIsPreventAutoscroll = (isPreventAutoscroll: boolean) => useUnauthTicketStore.setState({ isPreventAutoscroll })
export const setUnauthTicketMessageFetchState = (unauthTicketMessageFetchState: FetchState) => useUnauthTicketStore.setState({ unauthTicketMessageFetchState })
function sortMessagesByTime(ticket1: TicketMessage, ticket2: TicketMessage) {
const date1 = new Date(ticket1.created_at).getTime()
const date2 = new Date(ticket2.created_at).getTime()
return date1 - date2
}

@ -0,0 +1,37 @@
export const MAX_FILE_SIZE = 10485760;
const MAX_PHOTO_SIZE = 5242880;
const MAX_VIDEO_SIZE = 52428800;
export const ACCEPT_SEND_MEDIA_TYPES_MAP = {
picture: ["jpg", "png"],
video: ["mp4"],
document: ["doc", "docx", "pdf", "txt", "xlsx", "csv"],
} as const;
const TOO_LARGE_TEXT = "Файл слишком большой";
export const checkAcceptableMediaType = (file: File) => {
if (file === null) return "";
const segments = file?.name.split(".");
const extension = segments[segments.length - 1];
const type = extension.toLowerCase();
console.log(type);
switch (type) {
case ACCEPT_SEND_MEDIA_TYPES_MAP.document.find((name) => name === type):
if (file.size > MAX_FILE_SIZE) return TOO_LARGE_TEXT;
return "";
case ACCEPT_SEND_MEDIA_TYPES_MAP.picture.find((name) => name === type):
if (file.size > MAX_PHOTO_SIZE) return TOO_LARGE_TEXT;
return "";
case ACCEPT_SEND_MEDIA_TYPES_MAP.video.find((name) => name === type):
if (file.size > MAX_VIDEO_SIZE) return TOO_LARGE_TEXT;
return "";
default:
return "Не удалось отправить файл. Недопустимый тип";
}
};

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { GetHistoryResponse, getHistory } from "@root/api/history";
import { setHistory, useHistoryStore } from "@root/stores/history";
import { setHistory } from "@root/stores/history";
export const useHistoryData = () => {
@ -8,7 +8,6 @@ export const useHistoryData = () => {
async function fetchData() {
try {
const [response, errorMsg] = await getHistory();
if (errorMsg) {
console.error("Произошла ошибка при вызове getHistory:", errorMsg);
}

@ -1,26 +1,19 @@
import {useEffect, useState} from "react"
import { useTariffStore } from "@root/stores/tariffs";
import {getRecentlyPurchasedTariffs} from "@root/api/recentlyPurchasedTariffs"
import { getTariffById } from "@root/api/tariff"
import {useHistoryStore} from "@stores/history"
export const useRecentlyPurchasedTariffs = () => {
const [recentlyPurchased, setRecentlyPurchased] = useState<any>([])
const tariffs = useTariffStore((state) => state.tariffs);
const historyData = useHistoryStore(state => state.history);
useEffect(() => {
console.log("юзэффект начинает работаььб")
async function fetchData() {
try {
const [response, errorMsg] = await getRecentlyPurchasedTariffs();
console.log("responce" , response)
if (errorMsg) {
console.error("Произошла ошибка при вызове getRecentlyPurchasedTariffs:", errorMsg);
}
if (response) {
const recentlyTariffs = response.slice(0, 10).map((obj: { id: string })=>obj.id)
console.log("responce22222222222222222" , recentlyTariffs)
console.log("tariffstariffstariffstariffstariffstariffs" , tariffs)
setRecentlyPurchased(tariffs.filter((tariffs)=>{
return recentlyTariffs.includes(tariffs._id)}));
}
@ -31,6 +24,6 @@ console.log("responce" , response)
fetchData();
}, [tariffs]);
console.log(recentlyPurchased)
return {recentlyPurchased}
}
}

@ -4002,7 +4002,6 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001591:
version "1.0.30001593"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001593.tgz#7cda1d9e5b0cad6ebab4133b1f239d4ea44fe659"
integrity sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ==
canvas@^2.11.2:
version "2.11.2"
resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860"
@ -5756,7 +5755,6 @@ fast-glob@^3.2.9, fast-glob@^3.3.0:
version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
@ -6336,7 +6334,6 @@ hasown@^2.0.0, hasown@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa"
integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==
dependencies:
function-bind "^1.1.2"
he@^1.2.0:
@ -7015,7 +7012,6 @@ jake@^10.8.5:
version "10.8.7"
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f"
integrity sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==
dependencies:
async "^3.2.3"
chalk "^4.0.2"
filelist "^1.0.4"
@ -8259,7 +8255,6 @@ lz-string@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -8421,7 +8416,6 @@ minimatch@^5.0.1:
version "5.1.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8:
@ -8754,7 +8748,6 @@ open@^8.0.9, open@^8.4.0:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"
@ -9517,7 +9510,6 @@ postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.11, postcss-select
version "6.0.15"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
@ -10020,7 +10012,6 @@ regenerate-unicode-properties@^10.1.0:
version "10.1.1"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480"
integrity sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==
dependencies:
regenerate "^1.4.2"
regenerate@^1.4.2:
@ -10154,7 +10145,6 @@ resolve.exports@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999"
integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==
resolve.exports@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800"
@ -10397,7 +10387,6 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
dependencies:
randombytes "^2.1.0"
serve-index@^1.9.1: