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": { "env": {
"browser": true, "browser": true,
"es2021": true "es2021": true
}, },
"extends": [], "extends": [],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": "latest", "ecmaVersion": "latest",
"sourceType": "module" "sourceType": "module"
}, },
"plugins": [ "plugins": ["@typescript-eslint", "react"],
"@typescript-eslint", "rules": {
"react" "quotes": ["error", "double"]
], }
"rules": {
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"never"
]
}
} }

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

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

@ -6,7 +6,9 @@ import type { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import type { GetTariffsResponse } from "@root/model/tariff"; import type { GetTariffsResponse } from "@root/model/tariff";
import { removeTariffFromCart } from "@root/stores/user"; 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( export async function getTariffs(
apiPage: number, apiPage: number,

@ -1,46 +1,46 @@
import { makeRequest } from "@frontend/kitui" import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error" 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( export async function sendTicketMessage(
ticketId: string, ticketId: string,
message: string message: string
): Promise<[null, string?]> { ): Promise<[null, string?]> {
try { try {
const sendTicketMessageResponse = await makeRequest< const sendTicketMessageResponse = await makeRequest<
SendTicketMessageRequest, SendTicketMessageRequest,
null null
>({ >({
url: `${apiUrl}/send`, url: `${apiUrl}/send`,
method: "POST", method: "POST",
useToken: true, useToken: true,
body: { ticket: ticketId, message: message, lang: "ru", files: [] }, body: { ticket: ticketId, message: message, lang: "ru", files: [] },
}) });
return [sendTicketMessageResponse] return [sendTicketMessageResponse];
} catch (nativeError) { } catch (nativeError) {
const [error] = parseAxiosError(nativeError) const [error] = parseAxiosError(nativeError);
return [null, `Не удалось отправить сообщение. ${error}`] return [null, `Не удалось отправить сообщение. ${error}`];
} }
} }
export async function shownMessage(id: string): Promise<[null, string?]> { export async function shownMessage(id: string): Promise<[null, string?]> {
try { try {
const shownMessageResponse = await makeRequest<{ id: string }, null>({ const shownMessageResponse = await makeRequest<{ id: string }, null>({
url: apiUrl + "/shown", url: apiUrl + "/shown",
method: "POST", method: "POST",
useToken: true, useToken: true,
body: { id }, body: { id },
}) });
return [shownMessageResponse] return [shownMessageResponse];
} catch (nativeError) { } catch (nativeError) {
const [error] = parseAxiosError(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 upXs = useMediaQuery(theme.breakpoints.up("xs")); //300
const [isExpanded, setIsExpanded] = useState<boolean>(false); const [isExpanded, setIsExpanded] = useState<boolean>(false);
console.log(upXs);
return ( return (
<Box <Box
sx={{ sx={{

@ -1,5 +1,5 @@
import { useState, useRef } from "react"; 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 SectionWrapper from "./SectionWrapper";
import CustomWrapperDrawer from "./CustomWrapperDrawer"; import CustomWrapperDrawer from "./CustomWrapperDrawer";
import { NotificationsModal } from "./NotificationsModal"; import { NotificationsModal } from "./NotificationsModal";
@ -22,327 +22,341 @@ import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { setNotEnoughMoneyAmount, useCartStore } from "@root/stores/cart"; import { setNotEnoughMoneyAmount, useCartStore } from "@root/stores/cart";
function Drawers() { function Drawers() {
const [openNotificationsModal, setOpenNotificationsModal] = useState<boolean>(false); const [openNotificationsModal, setOpenNotificationsModal] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const bellRef = useRef<HTMLButtonElement | null>(null); const bellRef = useRef<HTMLButtonElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false); const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const cart = useCart(); const cart = useCart();
const userAccount = useUserStore((state) => state.userAccount); const userAccount = useUserStore((state) => state.userAccount);
const tickets = useTicketStore((state) => state.tickets); const tickets = useTicketStore((state) => state.tickets);
const notEnoughMoneyAmount = useCartStore(state => state.notEnoughMoneyAmount); const notEnoughMoneyAmount = useCartStore(state => state.notEnoughMoneyAmount);
const notificationsCount = tickets.filter( const notificationsCount = tickets.filter(
({ user, top_message }) => user !== top_message.user_id && top_message.shown.me !== 1 ({ user, top_message }) => user !== top_message.user_id && top_message.shown.me !== 1
).length; ).length;
async function handlePayClick() { async function handlePayClick() {
setLoading(true); setLoading(true);
const [payCartResponse, payCartError] = await payCart(); const [payCartResponse, payCartError] = await payCart();
if (payCartError) { if (payCartError) {
if (payCartError.includes("insufficient funds: ")) { if (payCartError.includes("insufficient funds: ")) {
const notEnoughMoneyAmount = parseInt(payCartError.replace(/^.*insufficient\sfunds:\s(?=\d+$)/, "")); const notEnoughMoneyAmount = parseInt(payCartError.replace(/^.*insufficient\sfunds:\s(?=\d+$)/, ""));
setNotEnoughMoneyAmount(notEnoughMoneyAmount); setNotEnoughMoneyAmount(notEnoughMoneyAmount);
} }
setLoading(false); setLoading(false);
setIsDrawerOpen(false); if (!payCartError.includes("insufficient funds: ")) enqueueSnackbar(payCartError);
navigate("payment") return;
if (!payCartError.includes("insufficient funds: ")) enqueueSnackbar(payCartError); }
return
if (payCartResponse) {
setUserAccount(payCartResponse);
}
setLoading(false);
setIsDrawerOpen(false);
} }
if (payCartResponse) { function handleReplenishWallet() {
setUserAccount(payCartResponse); setIsDrawerOpen(false);
navigate("/payment", { state: { notEnoughMoneyAmount } });
} }
setLoading(false); return (
setIsDrawerOpen(false);; <Box sx={{ display: "flex", gap: isTablet ? "10px" : "20px" }}>
} <IconButton
ref={bellRef}
function handleReplenishWallet() { aria-label="cart"
navigate("/payment", { state: { notEnoughMoneyAmount } }); onClick={() => setOpenNotificationsModal((isOpened) => !isOpened)}
}
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
sx={{ sx={{
width: upMd ? "100%" : undefined, cursor: "pointer",
display: "flex", borderRadius: "6px",
flexWrap: "wrap", background: openNotificationsModal ? theme.palette.purple.main : theme.palette.background.default,
flexDirection: "column", "& .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 <Badge
badgeContent={ badgeContent={notificationsCount}
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={{ sx={{
display: "flex", "& .MuiBadge-badge": {
flexDirection: upMd ? "column" : "row", display: notificationsCount ? "flex" : "none",
alignItems: upMd ? "start" : "center", color: "#FFFFFF",
mt: upMd ? "10px" : "30px", background: theme.palette.purple.main,
gap: "15px", 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 ? "Оплатить" : "Пополнить"} <BellIcon />
</Button> </Badge>
</Box> </IconButton>
</Box> <NotificationsModal
</Box> open={openNotificationsModal}
</SectionWrapper> setOpen={setOpenNotificationsModal}
</Drawer> anchorElement={bellRef.current}
</Box> 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, { export default withErrorBoundary(Drawers, {
fallback: ( fallback: (
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
}} }}
> >
<ErrorOutlineIcon color="error" /> <ErrorOutlineIcon color="error" />
</Box> </Box>
), ),
onError: handleComponentError, onError: handleComponentError,
}); });

@ -10,58 +10,85 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { TicketMessage } from "@frontend/kitui";
import { import {
addOrUpdateUnauthMessages, TicketMessage,
useUnauthTicketStore, makeRequest,
incrementUnauthMessageApiPage, useTicketsFetcher,
setUnauthIsPreventAutoscroll, useTicketMessages,
setUnauthSessionData, getMessageFromFetchError,
setIsMessageSending, useSSESubscription,
setUnauthTicketMessageFetchState, createTicket,
} from "@root/stores/unauthTicket"; } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ChatMessage from "../ChatMessage"; import ChatMessage from "../ChatMessage";
import SendIcon from "../icons/SendIcon"; import SendIcon from "../icons/SendIcon";
import ArrowLeft from "@root/assets/Icons/arrowLeft";
import UserCircleIcon from "./UserCircleIcon"; import UserCircleIcon from "./UserCircleIcon";
import { throttle } from "@frontend/kitui"; import { throttle } from "@frontend/kitui";
import {
useTicketMessages,
getMessageFromFetchError,
useSSESubscription,
useEventListener,
createTicket,
} from "@frontend/kitui";
import { sendTicketMessage, shownMessage } from "@root/api/ticket"; import { sendTicketMessage, shownMessage } from "@root/api/ticket";
import { useSSETab } from "@root/utils/hooks/useSSETab"; 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 { interface Props {
open: boolean; open: boolean;
sx?: SxProps<Theme>; 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 theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(800));
const [messageField, setMessageField] = useState<string>(""); const [messageField, setMessageField] = useState<string>("");
const sessionData = useUnauthTicketStore((state) => state.sessionData); const [disableFileButton, setDisableFileButton] = useState(false);
const messages = useUnauthTicketStore((state) => state.messages); const [modalWarningType, setModalWarningType] =
const messageApiPage = useUnauthTicketStore((state) => state.apiPage); useState<ModalWarningType>(null);
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 chatBoxRef = useRef<HTMLDivElement>(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[]>( const { isActiveSSETab, updateSSEValue } = useSSETab<TicketMessage[]>(
"ticket", "ticket",
addOrUpdateUnauthMessages addOrUpdateUnauthMessages
@ -74,8 +101,10 @@ export default function Chat({ open = false, sx }: Props) {
messagesPerPage, messagesPerPage,
messageApiPage, messageApiPage,
onSuccess: useCallback((messages) => { onSuccess: useCallback((messages) => {
if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) if (chatBoxRef.current && chatBoxRef.current.scrollTop < 1) {
chatBoxRef.current.scrollTop = 1; chatBoxRef.current.scrollTop = 1;
}
addOrUpdateUnauthMessages(messages); addOrUpdateUnauthMessages(messages);
}, []), }, []),
onError: useCallback((error: Error) => { onError: useCallback((error: Error) => {
@ -92,6 +121,7 @@ export default function Chat({ open = false, sx }: Props) {
`/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`, `/heruvym/ticket?ticket=${sessionData?.ticketId}&s=${sessionData?.sessionId}`,
onNewData: (ticketMessages) => { onNewData: (ticketMessages) => {
updateSSEValue(ticketMessages); updateSSEValue(ticketMessages);
addOrUpdateUnauthMessages(ticketMessages); addOrUpdateUnauthMessages(ticketMessages);
}, },
onDisconnect: useCallback(() => { onDisconnect: useCallback(() => {
@ -100,6 +130,34 @@ export default function Chat({ open = false, sx }: Props) {
marker: "ticket", 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( const throttledScrollHandler = useMemo(
() => () =>
throttle(() => { throttle(() => {
@ -114,14 +172,12 @@ export default function Chat({ open = false, sx }: Props) {
if (fetchState !== "idle") return; if (fetchState !== "idle") return;
if (chatBox.scrollTop < chatBox.clientHeight) { if (chatBox.scrollTop < chatBox.clientHeight) {
incrementUnauthMessageApiPage(); incrementUnauthMessage();
} }
}, 200), }, 200),
[fetchState] [fetchState]
); );
useEventListener("scroll", throttledScrollHandler, chatBoxRef);
useEffect( useEffect(
function scrollOnNewMessage() { function scrollOnNewMessage() {
if (!chatBoxRef.current) return; if (!chatBoxRef.current) return;
@ -131,7 +187,6 @@ export default function Chat({ open = false, sx }: Props) {
scrollToBottom(); scrollToBottom();
}, 50); }, 50);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, },
[lastMessageId] [lastMessageId]
); );
@ -146,10 +201,18 @@ export default function Chat({ open = false, sx }: Props) {
} }
}, [open, messages]); }, [open, messages]);
const loadNewMessages = (
event: WheelEvent<HTMLDivElement> | TouchEvent<HTMLDivElement>
) => {
event.stopPropagation();
throttledScrollHandler();
};
async function handleSendMessage() { async function handleSendMessage() {
if (!messageField || isMessageSending) return; if (!messageField || isMessageSending) return;
if (!sessionData) { if (!sessionData?.ticketId) {
setIsMessageSending(true); setIsMessageSending(true);
createTicket({ createTicket({
url: process.env.REACT_APP_DOMAIN + "/heruvym/create", url: process.env.REACT_APP_DOMAIN + "/heruvym/create",
@ -157,10 +220,10 @@ export default function Chat({ open = false, sx }: Props) {
Title: "Unauth title", Title: "Unauth title",
Message: messageField, Message: messageField,
}, },
useToken: false, useToken: Boolean(user),
}) })
.then((response) => { .then((response) => {
setUnauthSessionData({ setTicketData({
ticketId: response.Ticket, ticketId: response.Ticket,
sessionId: response.sess, 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 ( return (
<> <>
{open && ( {open && (
@ -217,7 +340,9 @@ export default function Chat({ open = false, sx }: Props) {
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "clamp(250px, calc(100vh - 90px), 600px)", height: isMobile
? "100%"
: "clamp(250px, calc(100vh - 90px), 600px)",
backgroundColor: "#944FEE", backgroundColor: "#944FEE",
borderRadius: "8px", borderRadius: "8px",
...sx, ...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))", filter: "drop-shadow(0px 3px 12px rgba(37, 39, 52, 0.3))",
}} }}
> >
{isMobile && (
<IconButton onClick={onclickArrow}>
<ArrowLeft color="white" />
</IconButton>
)}
<UserCircleIcon /> <UserCircleIcon />
<Box <Box
sx={{ sx={{
@ -263,6 +393,8 @@ export default function Chat({ open = false, sx }: Props) {
}} }}
> >
<Box <Box
onWheel={loadNewMessages}
onTouchMove={loadNewMessages}
ref={chatBoxRef} ref={chatBoxRef}
sx={{ sx={{
display: "flex", display: "flex",
@ -276,16 +408,87 @@ export default function Chat({ open = false, sx }: Props) {
flexGrow: 1, flexGrow: 1,
}} }}
> >
{sessionData && {ticket.sessionData?.ticketId &&
messages.map((message) => ( messages.map((message) => {
<ChatMessage const isFileVideo = () => {
unAuthenticated if (message.files) {
key={message.id} return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(
text={message.message} (fileType) =>
createdAt={message.created_at} message.files[0].toLowerCase().endsWith(fileType)
isSelf={sessionData.sessionId === message.user_id} );
/> }
))} };
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> </Box>
<FormControl fullWidth sx={{ borderTop: "1px solid black" }}> <FormControl fullWidth sx={{ borderTop: "1px solid black" }}>
<InputBase <InputBase
@ -314,6 +517,26 @@ export default function Chat({ open = false, sx }: Props) {
onChange={(e) => setMessageField(e.target.value)} onChange={(e) => setMessageField(e.target.value)}
endAdornment={ endAdornment={
<InputAdornment position="end"> <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 <IconButton
disabled={isMessageSending} disabled={isMessageSending}
onClick={handleSendMessage} 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 { useState, useEffect, forwardRef } from "react";
import { Box, Fab, Typography, Badge, useTheme } from "@mui/material"; import {
Box,
Fab,
Typography,
Badge,
Dialog,
Slide,
useTheme,
useMediaQuery,
} from "@mui/material";
import CircleDoubleDown from "./CircleDoubleDownIcon"; import CircleDoubleDown from "./CircleDoubleDownIcon";
import Chat from "./Chat"; 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() { export default function FloatingSupportChat() {
const [monitorType, setMonitorType] = useState<"desktop" | "mobile" | "">("");
const [isChatOpened, setIsChatOpened] = useState<boolean>(false); const [isChatOpened, setIsChatOpened] = useState<boolean>(false);
const theme = useTheme(); 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 = { const animation = {
"@keyframes runningStripe": { "@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 ( return (
<Box <Box
sx={{ sx={{
@ -49,10 +104,20 @@ export default function FloatingSupportChat() {
}} }}
> >
<Chat <Chat
open={isChatOpened} open={isChatOpened && (monitorType === "desktop" || !isMobile)}
sx={{ alignSelf: "start", width: "clamp(200px, 100%, 400px)" }} 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 <Fab
disableRipple disableRipple
sx={{ sx={{

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

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

@ -7,6 +7,7 @@ import Wallet from "./pages/Wallet"
import Payment from "./pages/Payment/Payment" import Payment from "./pages/Payment/Payment"
import QuizPayment from "./pages/QuizPayment/QuizPayment" import QuizPayment from "./pages/QuizPayment/QuizPayment"
import Support from "./pages/Support/Support" import Support from "./pages/Support/Support"
import ChatImageNewWindow from "./pages/Support/ChatImageNewWindow"
import AccountSettings from "./pages/AccountSettings/AccountSettings" import AccountSettings from "./pages/AccountSettings/AccountSettings"
import Landing from "./pages/Landing/Landing" import Landing from "./pages/Landing/Landing"
import Tariffs from "./pages/Tariffs/Tariffs" 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" 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="/changepwd/expired" element={<Navigate to="/" replace state={{ redirectTo: "/changepwd/expired" }} />} />
<Route path={"/image/:srcImage"} element={<ChatImageNewWindow />} />
<Route element={<PrivateRoute />}> <Route element={<PrivateRoute />}>
<Route element={<ProtectedLayout />}> <Route element={<ProtectedLayout />}>
<Route path="/tariffs" element={<Tariffs />} /> <Route path="/tariffs" element={<Tariffs />} />

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

@ -37,6 +37,7 @@ export default function AccordionWrapper({ content, last, first, createdAt, onCl
content[0].Value[0].forEach((item) => { content[0].Value[0].forEach((item) => {
valuesByKey[item.Key] = item.Value valuesByKey[item.Key] = item.Value
}) })
console.log("Я врапер")
console.log(content) console.log(content)
console.log(content[0]) console.log(content[0])
console.log(content[0].Value) 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 { useEffect, useState } from "react";
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material" import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack" import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SectionWrapper from "@root/components/SectionWrapper" import SectionWrapper from "@root/components/SectionWrapper";
import { Select } from "@root/components/Select" import { Select } from "@root/components/Select";
import { Tabs } from "@root/components/Tabs" import { Tabs } from "@root/components/Tabs";
import AccordionWrapper from "./AccordionWrapper" import AccordionWrapper from "./AccordionWrapper";
import { HISTORY } from "./historyMocks" import { HISTORY } from "./historyMocks";
import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker" import { useHistoryTracker } from "@root/utils/hooks/useHistoryTracker";
import { useHistoryData } from "@root/utils/hooks/useHistoryData" import { useHistoryData } from "@root/utils/hooks/useHistoryData";
import { isArray } from "cypress/types/lodash" import { isArray } from "cypress/types/lodash";
import { ErrorBoundary } from "react-error-boundary" import { ErrorBoundary } from "react-error-boundary";
import { handleComponentError } from "@root/utils/handleComponentError" import { handleComponentError } from "@root/utils/handleComponentError";
import { useHistoryStore } from "@root/stores/history"; import { useHistoryStore } from "@root/stores/history";
import EmailIcon from '@mui/icons-material/Email'; import { enqueueSnackbar } from "notistack";
import {enqueueSnackbar} from "notistack" import { makeRequest } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui" import { HistoryRecord, HistoryRecord2 } from "@root/api/history";
import AccordionWrapper2 from "./AccordionWrapper2";
const subPages = ["Платежи"] const subPages = ["Платежи"];
// const subPages = ["Платежи", "Покупки тарифов", "Окончания тарифов"] // const subPages = ["Платежи", "Покупки тарифов", "Окончания тарифов"]
export default function History() { export default function History() {
const [selectedItem, setSelectedItem] = useState<number>(0) const [selectedItem, setSelectedItem] = useState<number>(0);
const theme = useTheme() const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")) const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(600)) const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)) const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const historyData = useHistoryStore(state => state.history) 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) => { async function handleHistoryResponse(tariffId: string) {
const dateMatch = tariffName.match(/\d{4}-\d{2}-\d{2}/) try {
return dateMatch ? dateMatch[0] : "" await makeRequest(
} {
url: process.env.REACT_APP_DOMAIN + `/customer/sendReport/${tariffId}`,
method: "POST",
}
);
enqueueSnackbar("Запрос отправлен");
} catch (e) {
enqueueSnackbar("извините, произошла ошибка");
}
}
async function handleHistoryResponse(tariffId: string) { return (
try { <SectionWrapper
await makeRequest ( maxWidth="lg"
{ sx={{
url: process.env.REACT_APP_DOMAIN + `/customer/sendReport/${tariffId}`, mt: upMd ? "25px" : "20px",
method: "POST", mb: upMd ? "70px" : "37px",
} px: isTablet ? (isTablet ? "18px" : "40px") : "20px",
) }}
enqueueSnackbar("Запрос отправлен") >
} catch (e) { <Box
enqueueSnackbar("извините, произошла ошибка") sx={{
} mt: "20px",
} mb: isTablet ? "38px" : "20px",
display: "flex",
return ( alignItems: "center",
<SectionWrapper gap: "10px",
maxWidth="lg" }}
sx={{ >
mt: upMd ? "25px" : "20px", {isMobile && (
mb: upMd ? "70px" : "37px", <IconButton onClick={handleCustomBackNavigation} sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
px: isTablet ? (isTablet ? "18px" : "40px") : "20px", <ArrowBackIcon />
}} </IconButton>
> )}
<Box <Typography
sx={{ sx={{
mt: "20px", fontSize: isMobile ? "24px" : "36px",
mb: isTablet ? "38px" : "20px", fontWeight: "500",
display: "flex", }}
alignItems: "center", >
gap: "10px", История
}} </Typography>
> </Box>
{isMobile && ( {isMobile ? (
<IconButton onClick={handleCustomBackNavigation} sx={{ p: 0, height: "28px", width: "28px", color: "black" }}> <Select items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
<ArrowBackIcon /> ) : (
</IconButton> <Tabs items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} />
)} )}
<Typography <ErrorBoundary
sx={{ fallback={
fontSize: isMobile ? "24px" : "36px", <Typography mt="8px">Ошибка загрузки истории</Typography>
fontWeight: "500", }
}} onError={handleComponentError}
> >
История {historyData?.length === 0 && <Typography textAlign="center">Нет данных</Typography>}
</Typography> {/* Для ненормального rawDetails */}
</Box> {historyData?.filter((e): e is HistoryRecord => {
{isMobile ? ( e.createdAt = extractDateFromString(e.createdAt);
<Select items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} /> return (
) : ( !e.isDeleted
<Tabs items={subPages} selectedItem={selectedItem} setSelectedItem={setSelectedItem} /> && e.key === "payCart"
)} && Array.isArray(e.rawDetails)
<ErrorBoundary && Array.isArray(e.rawDetails[0].Value)
fallback={ );
<Typography mt="8px">Ошибка загрузки истории</Typography> }).map((e, index) => {
} return (
onError={handleComponentError} <Box key={index} sx={{ mt: index === 0 ? "27px" : "0px" }}>
> <AccordionWrapper
{historyData?.length === 0 && <Typography textAlign="center" >Нет данных</Typography>} first={index === 0}
{historyData?.filter((e) => { last={index === historyData?.length - 1}
e.createdAt = extractDateFromString(e.createdAt) content={(e as HistoryRecord).rawDetails}
return(!e.isDeleted && e.key === "payCart" && Array.isArray(e.rawDetails[0].Value) key={e.id}
)}) createdAt={e.createdAt}
.map(( e, index) => { onClickMail={(event: any) => {
return ( event.stopPropagation();
<Box key={index} sx={{ mt: index === 0 ? "27px" : "0px" }}> handleHistoryResponse(e.id);
<AccordionWrapper }}
first={index === 0} />
last={index === historyData?.length - 1} </Box>
content={e.rawDetails} );
key={e.id} })}
createdAt={e.createdAt} {/* Для нормального rawDetails */}
onClickMail={(event: any)=>{ {historyData?.filter((e): e is HistoryRecord2 => {
event.stopPropagation() e.createdAt = extractDateFromString(e.createdAt);
handleHistoryResponse(e.id) return (
}} !e.isDeleted
/> && e.key === "payCart"
</Box> && !Array.isArray(e.rawDetails)
)})} && !!e.rawDetails.tariffs[0]
</ErrorBoundary> );
</SectionWrapper> }).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 ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import PaymentMethodCard from "./PaymentMethodCard"; import PaymentMethodCard from "./PaymentMethodCard";
import mastercardLogo from "@root/assets/bank-logo/logo-mastercard.png"; import umoneyLogo from "@root/assets/bank-logo/umaney.png";
import visaLogo from "@root/assets/bank-logo/logo-visa.png"; import b2bLogo from "@root/assets/bank-logo/b2b.png";
import qiwiLogo from "@root/assets/bank-logo/logo-qiwi.png"; import spbLogo from "@root/assets/bank-logo/spb.png";
import mirLogo from "@root/assets/bank-logo/logo-mir.png"; import sberpayLogo from "@root/assets/bank-logo/sberpay.png";
import tinkoffLogo from "@root/assets/bank-logo/logo-tinkoff.png"; import tinkoffLogo from "@root/assets/bank-logo/logo-tinkoff.png";
import rsPayLogo from "@root/assets/bank-logo/rs-pay.png"; import rsPayLogo from "@root/assets/bank-logo/rs-pay.png";
import { cardShadow } from "@root/utils/theme"; import { cardShadow } from "@root/utils/theme";
@ -37,11 +37,11 @@ type PaymentMethod = {
}; };
const paymentMethods: PaymentMethod[] = [ const paymentMethods: PaymentMethod[] = [
{ label: "Mastercard", name: "mastercard", image: mastercardLogo }, { label: "Тинькофф", name: "tinkoffBank", image: tinkoffLogo },
{ label: "Visa", name: "visa", image: visaLogo }, { label: "СБП", name: "sbp", image: spbLogo },
{ label: "QIWI Кошелек", name: "qiwi", image: qiwiLogo }, { label: "SberPay", name: "sberbank", image: sberpayLogo },
{ label: "Мир", name: "mir", image: mirLogo }, { label: "B2B Сбербанк", name: "b2bSberbank", image: b2bLogo },
{ label: "Тинькофф", name: "tinkoff", image: tinkoffLogo }, { label: "ЮMoney", name: "yoomoney", image: umoneyLogo },
]; ];
type PaymentMethodType = (typeof paymentMethods)[number]["name"]; type PaymentMethodType = (typeof paymentMethods)[number]["name"];
@ -53,7 +53,7 @@ export default function Payment() {
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [selectedPaymentMethod, setSelectedPaymentMethod] = const [selectedPaymentMethod, setSelectedPaymentMethod] =
useState<PaymentMethodType | null>("rspay"); useState<PaymentMethodType | null>("");
const [warnModalOpen, setWarnModalOpen] = useState<boolean>(false); const [warnModalOpen, setWarnModalOpen] = useState<boolean>(false);
const [sorryModalOpen, setSorryModalOpen] = useState<boolean>(false); const [sorryModalOpen, setSorryModalOpen] = useState<boolean>(false);
const [paymentValueField, setPaymentValueField] = useState<string>("0"); const [paymentValueField, setPaymentValueField] = useState<string>("0");
@ -90,14 +90,56 @@ export default function Payment() {
} }
if (Number(paymentValueField) === 0) { if (Number(paymentValueField) === 0) {
enqueueSnackbar("Введите сумму")
return; return;
} }
if (selectedPaymentMethod !== "rspay") { if (selectedPaymentMethod !== "rspay") {
const [sendPaymentResponse, sendPaymentError] = await sendPayment({ 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) { if (sendPaymentError) {
return enqueueSnackbar(sendPaymentError); return enqueueSnackbar(sendPaymentError);
} }
@ -107,6 +149,31 @@ export default function Payment() {
} }
return; 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 }) => ( {paymentMethods.map(({ name, label, image, unpopular = false }) => (
<PaymentMethodCard <PaymentMethodCard
isSelected={false} isSelected={selectedPaymentMethod === name}
key={name} key={name}
label={label} label={label}
image={image} image={image}
onClick={() => { onClick={() => {
setSorryModalOpen(true) setSelectedPaymentMethod(name)
// setSelectedPaymentMethod(name)
}} }}
unpopular={true} unpopular={false}
/> />
))} ))}
<PaymentMethodCard <PaymentMethodCard
isSelected={false} isSelected={selectedPaymentMethod === "rspay"}
label={"Расчётный счёт"} label={"Расчётный счёт"}
image={rsPayLogo} image={rsPayLogo}
onClick={async() => { onClick={async() => {
setSelectedPaymentMethod("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");
}} }}
unpopular={false} unpopular={false}
/> />
@ -262,49 +305,7 @@ export default function Payment() {
/> />
)} )}
</Box> </Box>
<Button {paymentLink ? (
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 ? (
<Button <Button
variant="pena-outlined-light" variant="pena-outlined-light"
component="a" component="a"
@ -340,7 +341,7 @@ export default function Payment() {
> >
Выбрать Выбрать
</Button> </Button>
)} */} )}
</Box> </Box>
</Box> </Box>
<WarnModal open={warnModalOpen} setOpen={setWarnModalOpen} /> <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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import SendIcon from "@components/icons/SendIcon"; import SendIcon from "@components/icons/SendIcon";
import { throttle, useToken } from "@frontend/kitui"; import { makeRequest, throttle, useToken } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { useTicketStore } from "@root/stores/tickets"; import { useTicketStore } from "@root/stores/tickets";
import { import {
@ -38,8 +38,18 @@ import { shownMessage, sendTicketMessage } from "@root/api/ticket";
import { withErrorBoundary } from "react-error-boundary"; import { withErrorBoundary } from "react-error-boundary";
import { handleComponentError } from "@root/utils/handleComponentError"; import { handleComponentError } from "@root/utils/handleComponentError";
import { useSSETab } from "@root/utils/hooks/useSSETab"; 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() { function SupportChat() {
console.log("сапортчат отрисовался");
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.up(460)); const isMobile = useMediaQuery(theme.breakpoints.up(460));
@ -52,6 +62,8 @@ function SupportChat() {
const isPreventAutoscroll = useMessageStore( const isPreventAutoscroll = useMessageStore(
(state) => state.isPreventAutoscroll (state) => state.isPreventAutoscroll
); );
const [disableFileButton, setDisableFileButton] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const token = useToken(); const token = useToken();
const ticketId = useParams().ticketId; const ticketId = useParams().ticketId;
const ticket = tickets.find((ticket) => ticket.id === ticketId); const ticket = tickets.find((ticket) => ticket.id === ticketId);
@ -127,7 +139,6 @@ function SupportChat() {
}, 50); }, 50);
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastMessageId] [lastMessageId]
); );
@ -177,6 +188,45 @@ function SupportChat() {
minute: "2-digit", 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 ( return (
<Box <Box
sx={{ sx={{
@ -262,14 +312,87 @@ function SupportChat() {
}} }}
> >
{ticket && {ticket &&
messages.map((message) => ( messages.map((message) => {
<ChatMessage const isFileVideo = () => {
key={message.id} if (message.files) {
text={message.message} return ACCEPT_SEND_MEDIA_TYPES_MAP.video.some(
createdAt={message.created_at} (fileType) =>
isSelf={ticket.user === message.user_id} 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>
</Box> </Box>
<FormControl> <FormControl>
@ -298,6 +421,27 @@ function SupportChat() {
endAdornment={ endAdornment={
!upMd && ( !upMd && (
<InputAdornment position="end"> <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 <IconButton
onClick={handleSendMessage} onClick={handleSendMessage}
sx={{ sx={{
@ -318,6 +462,27 @@ function SupportChat() {
</Box> </Box>
{upMd && ( {upMd && (
<Box sx={{ alignSelf: "end" }}> <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 <Button
variant="pena-contained-dark" variant="pena-contained-dark"
onClick={handleSendMessage} onClick={handleSendMessage}

@ -4,7 +4,6 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import NumberIcon from "@root/components/NumberIcon"; import NumberIcon from "@root/components/NumberIcon";
import { useDiscountStore } from "@root/stores/discounts"; import { useDiscountStore } from "@root/stores/discounts";
import { useHistoryStore } from "@root/stores/history";
import { useTariffStore } from "@root/stores/tariffs"; import { useTariffStore } from "@root/stores/tariffs";
import { addTariffToCart, useUserStore } from "@root/stores/user"; import { addTariffToCart, useUserStore } from "@root/stores/user";
import { calcIndividualTariffPrices } from "@root/utils/calcTariffPrices"; import { calcIndividualTariffPrices } from "@root/utils/calcTariffPrices";
@ -39,7 +38,6 @@ function TariffPage() {
const discounts = useDiscountStore((state) => state.discounts); const discounts = useDiscountStore((state) => state.discounts);
const purchasesAmount = useUserStore((state) => state.userAccount?.wallet.spent) ?? 0; const purchasesAmount = useUserStore((state) => state.userAccount?.wallet.spent) ?? 0;
const isUserNko = useUserStore((state) => state.userAccount?.status) === "nko"; const isUserNko = useUserStore((state) => state.userAccount?.status) === "nko";
const historyData = useHistoryStore((state) => state.history);
const currentTariffs = useCartTariffs(); const currentTariffs = useCartTariffs();
const handleCustomBackNavigation = usePrevLocation(location); const handleCustomBackNavigation = usePrevLocation(location);
@ -70,20 +68,6 @@ function TariffPage() {
return false 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) => { const createTariffElements = (filteredTariffs: Tariff[], addFreeTariff = false) => {
console.log(filteredTariffs) console.log(filteredTariffs)
const tariffElements = 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 { create } from "zustand";
import { devtools, persist } from "zustand/middleware"; import { devtools, persist } from "zustand/middleware";
type HistoryStore = { type HistoryStore = {
history: HistoryRecord[] | null; history: HistoryRecord[] | HistoryRecord2[] | null;
}; };
const initialState: HistoryStore = { const initialState: HistoryStore = {

@ -1,46 +1,183 @@
import { FetchState, Ticket } from "@frontend/kitui" import { FetchState, Ticket, TicketMessage } from "@frontend/kitui";
import { create } from "zustand" import { create } from "zustand";
import { devtools } from "zustand/middleware" 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 { interface TicketStore {
ticketCount: number; ticketCount: number;
tickets: Ticket[]; tickets: Ticket[];
apiPage: number; apiPage: number;
ticketsPerPage: number; ticketsPerPage: number;
ticketsFetchState: FetchState; 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 = { const initialState: TicketStore = {
ticketCount: 0, ticketCount: 0,
tickets: [], tickets: [],
apiPage: 0, apiPage: 0,
ticketsPerPage: 10, ticketsPerPage: 10,
ticketsFetchState: "idle", ticketsFetchState: "idle",
} authData: initAuthData,
unauthData: initAuthData,
};
export const useTicketStore = create<TicketStore>()( export const useTicketStore = create<TicketStore>()(
devtools( persist(
(set, get) => initialState, devtools((set, get) => initialState, {
{ name: "Unauth tickets",
name: "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[]) => { export const updateTickets = (receivedTickets: Ticket[]) => {
const state = useTicketStore.getState() const state = useTicketStore.getState();
const ticketIdToTicketMap: { [ticketId: string]: Ticket; } = {}; 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 { useEffect, useState } from "react";
import { GetHistoryResponse, getHistory } from "@root/api/history"; import { GetHistoryResponse, getHistory } from "@root/api/history";
import { setHistory, useHistoryStore } from "@root/stores/history"; import { setHistory } from "@root/stores/history";
export const useHistoryData = () => { export const useHistoryData = () => {
@ -8,7 +8,6 @@ export const useHistoryData = () => {
async function fetchData() { async function fetchData() {
try { try {
const [response, errorMsg] = await getHistory(); const [response, errorMsg] = await getHistory();
if (errorMsg) { if (errorMsg) {
console.error("Произошла ошибка при вызове getHistory:", errorMsg); console.error("Произошла ошибка при вызове getHistory:", errorMsg);
} }

@ -1,26 +1,19 @@
import {useEffect, useState} from "react" import {useEffect, useState} from "react"
import { useTariffStore } from "@root/stores/tariffs"; import { useTariffStore } from "@root/stores/tariffs";
import {getRecentlyPurchasedTariffs} from "@root/api/recentlyPurchasedTariffs" import {getRecentlyPurchasedTariffs} from "@root/api/recentlyPurchasedTariffs"
import { getTariffById } from "@root/api/tariff"
import {useHistoryStore} from "@stores/history"
export const useRecentlyPurchasedTariffs = () => { export const useRecentlyPurchasedTariffs = () => {
const [recentlyPurchased, setRecentlyPurchased] = useState<any>([]) const [recentlyPurchased, setRecentlyPurchased] = useState<any>([])
const tariffs = useTariffStore((state) => state.tariffs); const tariffs = useTariffStore((state) => state.tariffs);
const historyData = useHistoryStore(state => state.history);
useEffect(() => { useEffect(() => {
console.log("юзэффект начинает работаььб")
async function fetchData() { async function fetchData() {
try { try {
const [response, errorMsg] = await getRecentlyPurchasedTariffs(); const [response, errorMsg] = await getRecentlyPurchasedTariffs();
console.log("responce" , response)
if (errorMsg) { if (errorMsg) {
console.error("Произошла ошибка при вызове getRecentlyPurchasedTariffs:", errorMsg); console.error("Произошла ошибка при вызове getRecentlyPurchasedTariffs:", errorMsg);
} }
if (response) { if (response) {
const recentlyTariffs = response.slice(0, 10).map((obj: { id: string })=>obj.id) const recentlyTariffs = response.slice(0, 10).map((obj: { id: string })=>obj.id)
console.log("responce22222222222222222" , recentlyTariffs)
console.log("tariffstariffstariffstariffstariffstariffs" , tariffs)
setRecentlyPurchased(tariffs.filter((tariffs)=>{ setRecentlyPurchased(tariffs.filter((tariffs)=>{
return recentlyTariffs.includes(tariffs._id)})); return recentlyTariffs.includes(tariffs._id)}));
} }
@ -31,6 +24,6 @@ console.log("responce" , response)
fetchData(); fetchData();
}, [tariffs]); }, [tariffs]);
console.log(recentlyPurchased)
return {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" version "1.0.30001593"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001593.tgz#7cda1d9e5b0cad6ebab4133b1f239d4ea44fe659" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001593.tgz#7cda1d9e5b0cad6ebab4133b1f239d4ea44fe659"
integrity sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ== integrity sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ==
canvas@^2.11.2: canvas@^2.11.2:
version "2.11.2" version "2.11.2"
resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" 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" version "3.3.2"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
dependencies:
"@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3" "@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2" glob-parent "^5.1.2"
@ -6336,7 +6334,6 @@ hasown@^2.0.0, hasown@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa"
integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA== integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==
dependencies:
function-bind "^1.1.2" function-bind "^1.1.2"
he@^1.2.0: he@^1.2.0:
@ -7015,7 +7012,6 @@ jake@^10.8.5:
version "10.8.7" version "10.8.7"
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f" resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f"
integrity sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w== integrity sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==
dependencies:
async "^3.2.3" async "^3.2.3"
chalk "^4.0.2" chalk "^4.0.2"
filelist "^1.0.4" filelist "^1.0.4"
@ -8259,7 +8255,6 @@ lz-string@^1.5.0:
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.25.0, magic-string@^0.25.7: magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9" version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" 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" version "5.1.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
dependencies:
brace-expansion "^2.0.1" brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: 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" version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0" define-lazy-prop "^2.0.0"
is-docker "^2.1.1" is-docker "^2.1.1"
is-wsl "^2.2.0" 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" version "6.0.15"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw== integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
dependencies:
cssesc "^3.0.0" cssesc "^3.0.0"
util-deprecate "^1.0.2" util-deprecate "^1.0.2"
@ -10020,7 +10012,6 @@ regenerate-unicode-properties@^10.1.0:
version "10.1.1" version "10.1.1"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480"
integrity sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q== integrity sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==
dependencies:
regenerate "^1.4.2" regenerate "^1.4.2"
regenerate@^1.4.2: regenerate@^1.4.2:
@ -10154,7 +10145,6 @@ resolve.exports@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999" resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-1.1.1.tgz#05cfd5b3edf641571fd46fa608b610dda9ead999"
integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ== integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==
resolve.exports@^2.0.0: resolve.exports@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" 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" version "6.0.2"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
dependencies:
randombytes "^2.1.0" randombytes "^2.1.0"
serve-index@^1.9.1: serve-index@^1.9.1: