Merge branch 'dev' into 'main'

Dev

See merge request frontend/marketplace!35
This commit is contained in:
Nastya 2023-08-12 22:02:48 +00:00
commit de0f243e62
35 changed files with 1279 additions and 963 deletions

@ -14,7 +14,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.17", "@frontend/kitui": "1.0.21",
"@mui/icons-material": "^5.10.14", "@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",

@ -1,10 +1,12 @@
import { Tariff, makeRequest } from "@frontend/kitui"; import { Tariff, makeRequest } from "@frontend/kitui";
import { CreateTariffBody, CustomTariff } from "@root/model/customTariffs"; import { CreateTariffBody } from "@root/model/customTariffs";
const apiUrl = process.env.NODE_ENV === "production" ? "/strator" : "https://hub.pena.digital/strator";
export function createTariff(tariff: CreateTariffBody) { export function createTariff(tariff: CreateTariffBody) {
return makeRequest<CreateTariffBody, CustomTariff>({ return makeRequest<CreateTariffBody, Tariff>({
url: `https://admin.pena.digital/strator/tariff`, url: `${apiUrl}/tariff`,
method: "post", method: "post",
useToken: true, useToken: true,
body: tariff, body: tariff,
@ -13,7 +15,7 @@ export function createTariff(tariff: CreateTariffBody) {
export function getTariffById(tariffId:string){ export function getTariffById(tariffId:string){
return makeRequest<never, Tariff>({ return makeRequest<never, Tariff>({
url: `https://admin.pena.digital/strator/tariff/${tariffId}`, url: `${apiUrl}/tariff/${tariffId}`,
method: "get", method: "get",
useToken: true, useToken: true,
}); });

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="black" xmlns="http://www.w3.org/2000/svg">
<path d="M0.400391 0.399902L19.6004 19.5999M0.400391 19.5999L19.6004 0.399902" />
</svg>

After

Width:  |  Height:  |  Size: 200 B

@ -0,0 +1,159 @@
import { useState } from "react";
import {
Box, Typography,
IconButton,
useMediaQuery,
useTheme
} from "@mui/material";
import ExpandIcon from "@components/icons/ExpandIcon";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack";
import { TariffCartData, getMessageFromFetchError } from "@frontend/kitui";
import { ReactComponent as CrossIcon } from "@root/assets/Icons/cross.svg";
interface Props {
tariffCartData: TariffCartData;
}
export default function CustomTariffAccordion({ tariffCartData }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const [isExpanded, setIsExpanded] = useState<boolean>(false);
function handleDeleteClick() {
removeTariffFromCart(tariffCartData.tariffId)
.then(() => {
enqueueSnackbar("Тариф удален");
})
.catch((error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
return (
<Box
sx={{
overflow: "hidden",
}}
>
<Box
sx={{
backgroundColor: "white",
}}
>
<Box
onClick={() => setIsExpanded((prev) => !prev)}
sx={{
height: "72px",
pr: "20px",
pl: "30px",
display: "flex",
gap: "15px",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
}}
>
<Box
sx={{
width: "50px",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<ExpandIcon isExpanded={isExpanded} />
</Box>
<Typography
sx={{
width: "100%",
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 400,
color: theme.palette.text.secondary,
px: 0,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
Кастомный тариф
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
height: "100%",
alignItems: "center",
gap: upSm ? "111px" : "17px",
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(tariffCartData.price / 100)}
</Typography>
</Box>
<IconButton
onClick={handleDeleteClick}
sx={{ padding: 0, height: "22px", width: "22px" }}
>
<CrossIcon style={{ height: "22 px", width: "22px" }} />
</IconButton>
</Box>
{isExpanded && tariffCartData.privileges.map((privilege) => (
<Box
key={privilege.privilegeId}
sx={{
px: "50px",
py: upMd ? "15px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "25px",
backgroundColor: "#F1F2F6",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "15px",
}}
>
<Typography
sx={{
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main,
width: "100%",
}}
>
{privilege.description}
</Typography>
<Box
sx={{
width: upSm ? "140px" : "123px",
marginRight: upSm ? "65px" : 0,
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(privilege.price / 100)}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
);
}

@ -1,14 +1,30 @@
import { useState } from "react"; import { useState } from "react";
import { Box, SvgIcon, Typography, useMediaQuery, useTheme } from "@mui/material"; import {
Box,
SvgIcon,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user"; import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { ServiceCartData, getMessageFromFetchError } from "@frontend/kitui"; import { ServiceCartData, getMessageFromFetchError } from "@frontend/kitui";
import ExpandIcon from "@components/icons/ExpandIcon";
import { ReactComponent as CrossIcon } from "@root/assets/Icons/cross.svg";
const name: Record<string, string> = { templategen: "Шаблонизатор", squiz: "Опросник", reducer: "Скоращатель ссылок" }; import type { MouseEvent } from "react";
import CustomTariffAccordion from "@root/components/CustomTariffAccordion";
const name: Record<string, string> = {
templategen: "Шаблонизатор",
squiz: "Опросник",
reducer: "Скоращатель ссылок",
};
interface Props { interface Props {
serviceData: ServiceCartData; serviceData: ServiceCartData;
@ -21,19 +37,36 @@ export default function CustomWrapperDrawer({ serviceData }: Props) {
const [isExpanded, setIsExpanded] = useState<boolean>(false); const [isExpanded, setIsExpanded] = useState<boolean>(false);
function handleItemDeleteClick(tariffId: string) { function handleItemDeleteClick(tariffId: string) {
removeTariffFromCart(tariffId).then(() => { removeTariffFromCart(tariffId)
enqueueSnackbar("Тариф удален"); .then(() => {
}).catch(error => { enqueueSnackbar("Тариф удален");
const message = getMessageFromFetchError(error); })
if (message) enqueueSnackbar(message); .catch((error) => {
}); const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
} }
const deleteService = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setIsExpanded(false);
for (const { tariffId } of serviceData.tariffs) {
try {
await removeTariffFromCart(tariffId);
} catch { }
}
enqueueSnackbar("Тарифы удален");
};
return ( return (
<Box <Box
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
borderRadius: "12px", borderRadius: "12px",
width: "100%",
}} }}
> >
<Box <Box
@ -56,101 +89,132 @@ export default function CustomWrapperDrawer({ serviceData }: Props) {
onClick={() => setIsExpanded((prev) => !prev)} onClick={() => setIsExpanded((prev) => !prev)}
sx={{ sx={{
height: "72px", height: "72px",
display: "flex", display: "flex",
gap: "10px",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
cursor: "pointer", cursor: "pointer",
userSelect: "none", userSelect: "none",
}} }}
> >
<Typography <ExpandIcon isExpanded={isExpanded} />
sx={{
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: theme.palette.text.secondary,
px: 0,
}}
>
{name[serviceData.serviceKey]}
</Typography>
<Box <Box
sx={{ sx={{
width: "100%",
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "space-between",
height: "100%",
alignItems: "center",
}} }}
> >
<Typography <Typography
sx={{ pr: "11px", color: theme.palette.grey3.main, fontSize: upSm ? "20px" : "16px", fontWeight: 500 }} sx={{
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: theme.palette.text.secondary,
px: 0,
}}
> >
{currencyFormatter.format(serviceData.price / 100)} {name[serviceData.serviceKey]}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
paddingLeft: upSm ? "24px" : 0, display: "flex",
justifyContent: "flex-end",
height: "100%", height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center", alignItems: "center",
}} }}
></Box>
</Box>
</Box>
{isExpanded &&
serviceData.privileges.map(privilege => (
<Box
key={privilege.tariffId + privilege.privilegeId}
sx={{
py: upMd ? "10px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "15px",
}}
> >
<Typography <Typography
sx={{ sx={{
width: "200px",
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main, color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}} }}
> >
{privilege.description} {currencyFormatter.format(serviceData.price / 100)}
</Typography> </Typography>
</Box>
</Box>
<IconButton
onClick={deleteService}
sx={{
padding: "3px",
height: "30px",
width: "30px",
}}
>
<CrossIcon
style={{
height: "24px",
width: "24px",
stroke: "#7E2AEA",
}}
/>
</IconButton>
</Box>
{isExpanded &&
serviceData.tariffs.map(tariff => {
const privilege = tariff.privileges[0];
return tariff.privileges.length > 1 ? (
<CustomTariffAccordion
key={privilege.tariffId + privilege.privilegeId}
tariffCartData={tariff}
/>
) : (
<Box <Box
key={privilege.tariffId + privilege.privilegeId}
sx={{ sx={{
py: upMd ? "10px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "20px",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
gap: "10px",
alignItems: "center", alignItems: "center",
gap: "15px",
}} }}
> >
<Typography <Typography
sx={{ sx={{
width: "200px",
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main, color: theme.palette.grey3.main,
fontSize: "20px",
fontWeight: 500,
}} }}
> >
{currencyFormatter.format(privilege.price / 100)} {privilege.description}
</Typography> </Typography>
<Box
<SvgIcon sx={{
sx={{ cursor: "pointer", color: "#7E2AEA" }} display: "flex",
onClick={() => handleItemDeleteClick(privilege.tariffId)} justifyContent: "space-between",
component={ClearIcon} gap: "10px",
/> alignItems: "center",
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: "20px",
fontWeight: 500,
}}
>
{currencyFormatter.format(privilege.price / 100)}
</Typography>
<SvgIcon
sx={{
cursor: "pointer",
width: "30px",
color: "#7E2AEA",
}}
onClick={() => handleItemDeleteClick(privilege.tariffId)}
component={ClearIcon}
/>
</Box>
</Box> </Box>
</Box> );
))} })}
</Box> </Box>
</Box> </Box>
); );

@ -6,11 +6,9 @@ import {
useTheme, useTheme,
Box, Box,
IconButton, IconButton,
SvgIcon,
Badge, Badge,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ClearIcon from "@mui/icons-material/Clear";
import { useTickets } from "@frontend/kitui"; import { useTickets } from "@frontend/kitui";
import SectionWrapper from "./SectionWrapper"; import SectionWrapper from "./SectionWrapper";
import CustomWrapperDrawer from "./CustomWrapperDrawer"; import CustomWrapperDrawer from "./CustomWrapperDrawer";
@ -24,7 +22,6 @@ import {
openCartDrawer, openCartDrawer,
useCartStore, useCartStore,
} from "@root/stores/cart"; } from "@root/stores/cart";
import { useCustomTariffsStore } from "@root/stores/customTariffs";
import { useUserStore } from "@root/stores/user"; import { useUserStore } from "@root/stores/user";
import { import {
updateTickets, updateTickets,
@ -34,6 +31,7 @@ import {
import { ReactComponent as BellIcon } from "@root/assets/Icons/bell.svg"; import { ReactComponent as BellIcon } from "@root/assets/Icons/bell.svg";
import { ReactComponent as CartIcon } from "@root/assets/Icons/cart.svg"; import { ReactComponent as CartIcon } from "@root/assets/Icons/cart.svg";
import { ReactComponent as CrossIcon } from "@root/assets/Icons/cross.svg";
export default function Drawers() { export default function Drawers() {
const [openNotificationsModal, setOpenNotificationsModal] = const [openNotificationsModal, setOpenNotificationsModal] =
@ -44,12 +42,6 @@ export default function Drawers() {
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isDrawerOpen = useCartStore((state) => state.isDrawerOpen); const isDrawerOpen = useCartStore((state) => state.isDrawerOpen);
const cart = useCart(); const cart = useCart();
const summaryPriceBeforeDiscountsMap = useCustomTariffsStore(
(state) => state.summaryPriceBeforeDiscountsMap
);
const summaryPriceAfterDiscountsMap = useCustomTariffsStore(
(state) => state.summaryPriceAfterDiscountsMap
);
const userAccount = useUserStore((state) => state.userAccount); const userAccount = useUserStore((state) => state.userAccount);
const { tickets, apiPage, ticketsPerPage } = useTicketStore((state) => state); const { tickets, apiPage, ticketsPerPage } = useTicketStore((state) => state);
@ -64,21 +56,12 @@ export default function Drawers() {
onError: () => {}, onError: () => {},
}); });
const basePrice = Object.values(summaryPriceBeforeDiscountsMap).reduce(
(a, e) => a + e,
0
);
const discountedPrice = Object.values(summaryPriceAfterDiscountsMap).reduce(
(a, e) => a + e,
0
);
const notificationsCount = tickets.filter( const notificationsCount = tickets.filter(
({ user, top_message }) => user !== top_message.user_id ({ user, top_message }) => user !== top_message.user_id
).length; ).length;
const totalPriceBeforeDiscounts = cart.priceBeforeDiscounts + basePrice; const totalPriceBeforeDiscounts = cart.priceBeforeDiscounts;
const totalPriceAfterDiscounts = cart.priceAfterDiscounts + discountedPrice; const totalPriceAfterDiscounts = cart.priceAfterDiscounts;
return ( return (
<Box sx={{ display: "flex", gap: "20px" }}> <Box sx={{ display: "flex", gap: "20px" }}>
@ -227,15 +210,23 @@ export default function Drawers() {
Корзина Корзина
</Typography> </Typography>
<IconButton onClick={closeCartDrawer} sx={{ p: 0 }}> <IconButton onClick={closeCartDrawer} sx={{ p: 0 }}>
<SvgIcon component={ClearIcon} /> <CrossIcon />
</IconButton> </IconButton>
</Box> </Box>
<Box sx={{ pl: "20px", pr: "20px" }}> <Box sx={{ pl: "20px", pr: "20px" }}>
{cart.services.map((serviceData) => ( {cart.services.map((serviceData) => (
<CustomWrapperDrawer <Box
key={serviceData.serviceKey} key={serviceData.serviceKey}
serviceData={serviceData} sx={{
/> display: "flex",
alignItems: "center",
}}
>
<CustomWrapperDrawer
key={serviceData.serviceKey}
serviceData={serviceData}
/>
</Box>
))} ))}
<Box <Box
sx={{ sx={{

@ -14,7 +14,6 @@ export default function Menu() {
const location = useLocation(); const location = useLocation();
const color = location.pathname === "/" ? "white" : "black"; const color = location.pathname === "/" ? "white" : "black";
console.log(location)
const arrayMenu: MenuItem[] = [ const arrayMenu: MenuItem[] = [
{ {
@ -42,49 +41,50 @@ export default function Menu() {
overflow: "hidden", overflow: "hidden",
}} }}
> >
{location.pathname !== "/" ? arrayMenu.map(({ name, url, subMenu = [] }) => ( {location.pathname !== "/"
<Link ? arrayMenu.map(({ name, url, subMenu = [] }) => (
key={name} <Link
style={{ key={name}
textDecoration: "none", style={{
height: "100%", textDecoration: "none",
display: "flex", height: "100%",
alignItems: "center", display: "flex",
}} alignItems: "center",
to={url} }}
onMouseEnter={() => setActiveSubMenu(subMenu)} to={url}
> onMouseEnter={() => setActiveSubMenu(subMenu)}
<Typography state={{ previousUrl: location.pathname }}
color={ >
location.pathname === url <Typography
? theme.palette.brightPurple.main color={
: color location.pathname === url
} ? theme.palette.brightPurple.main
variant="body2" : color
sx={{ }
whiteSpace: "nowrap", variant="body2"
}} sx={{
> whiteSpace: "nowrap",
{name} }}
</Typography> >
</Link> {name}
)) </Typography>
:arrayMenu.map(({ name, url, subMenu = [] }) => ( </Link>
<Typography ))
color={ : arrayMenu.map(({ name, url, subMenu = [] }) => (
location.pathname === url <Typography
? theme.palette.brightPurple.main color={
: color location.pathname === url
} ? theme.palette.brightPurple.main
variant="body2" : color
sx={{ }
whiteSpace: "nowrap", variant="body2"
}} sx={{
> whiteSpace: "nowrap",
{name} }}
</Typography> >
)) {name}
} </Typography>
))}
<Box <Box
sx={{ sx={{
zIndex: "10", zIndex: "10",
@ -96,29 +96,30 @@ export default function Menu() {
}} }}
onMouseLeave={() => setActiveSubMenu([])} onMouseLeave={() => setActiveSubMenu([])}
> >
{location.pathname !== "/" && activeSubMenu.map(({ name, url }) => ( {location.pathname !== "/" &&
<Link key={name} style={{ textDecoration: "none" }} to={url}> activeSubMenu.map(({ name, url }) => (
<Typography <Link key={name} style={{ textDecoration: "none" }} to={url}>
color={ <Typography
location.pathname === url color={
? theme.palette.brightPurple.main location.pathname === url
: color ? theme.palette.brightPurple.main
} : color
variant="body2" }
sx={{ variant="body2"
padding: "15px", sx={{
whiteSpace: "nowrap", padding: "15px",
paddingLeft: "185px", whiteSpace: "nowrap",
"&:hover": { paddingLeft: "185px",
color: theme.palette.brightPurple.main, "&:hover": {
background: theme.palette.background.default, color: theme.palette.brightPurple.main,
}, background: theme.palette.background.default,
}} },
> }}
{name} >
</Typography> {name}
</Link> </Typography>
))} </Link>
))}
</Box> </Box>
</Box> </Box>
); );

@ -85,7 +85,7 @@ export default function DialogMenu({ handleClose }: DialogMenuProps) {
key={index} key={index}
component={Link} component={Link}
to={url} to={url}
state={user ? undefined : { backgroundLocation: location }} state={{ previousUrl: location.pathname }}
disableRipple disableRipple
variant="text" variant="text"
onClick={() => onClick={() =>
@ -134,6 +134,7 @@ export default function DialogMenu({ handleClose }: DialogMenuProps) {
}} }}
to={url} to={url}
onClick={closeDialogMenu} onClick={closeDialogMenu}
state={{ previousUrl: location.pathname }}
> >
<Typography <Typography
variant="body2" variant="body2"

@ -58,6 +58,7 @@ export default function NavbarFull({ isLoggedIn, children }: Props) {
disableGutters disableGutters
maxWidth={false} maxWidth={false}
sx={{ sx={{
zIndex: 1,
position: "fixed", position: "fixed",
top: "0", top: "0",
px: "16px", px: "16px",

@ -1,16 +1,26 @@
import { useTheme } from "@mui/material"; import { useTheme , Box} from "@mui/material";
interface Props { interface Props {
isExpanded: boolean; isExpanded: boolean;
} }
export default function ExpandIcon({ isExpanded }: Props) { export default function ExpandIcon({ isExpanded }: Props) {
const theme = useTheme(); const theme = useTheme();
return ( return (
<svg style={{ transform: isExpanded ? "rotate(180deg)" : undefined }} xmlns="http://www.w3.org/2000/svg" width="32" height="33" viewBox="0 0 32 33" fill="none"> <Box sx={{
width: "33px",
height: "33px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}>
<svg style={{ transform: isExpanded ? "rotate(180deg)" : undefined }} xmlns="http://www.w3.org/2000/svg" width="32" height="33" viewBox="0 0 32 33" fill="none">
<path stroke={isExpanded ? theme.palette.orange.main : theme.palette.brightPurple.main} d="M16 28.7949C22.6274 28.7949 28 23.4223 28 16.7949C28 10.1675 22.6274 4.79492 16 4.79492C9.37258 4.79492 4 10.1675 4 16.7949C4 23.4223 9.37258 28.7949 16 28.7949Z" strokeWidth="2" strokeMiterlimit="10" /> <path stroke={isExpanded ? theme.palette.orange.main : theme.palette.brightPurple.main} d="M16 28.7949C22.6274 28.7949 28 23.4223 28 16.7949C28 10.1675 22.6274 4.79492 16 4.79492C9.37258 4.79492 4 10.1675 4 16.7949C4 23.4223 9.37258 28.7949 16 28.7949Z" strokeWidth="2" strokeMiterlimit="10" />
<path stroke={isExpanded ? theme.palette.orange.main : theme.palette.brightPurple.main} d="M20.5 15.2949L16 20.2949L11.5 15.2949" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path stroke={isExpanded ? theme.palette.orange.main : theme.palette.brightPurple.main} d="M20.5 15.2949L16 20.2949L11.5 15.2949" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
); </Box>
} )
}

@ -12,7 +12,7 @@ import Tariffs from "./pages/Tariffs/Tariffs";
import SigninDialog from "./pages/auth/Signin"; import SigninDialog from "./pages/auth/Signin";
import SignupDialog from "./pages/auth/Signup"; import SignupDialog from "./pages/auth/Signup";
import History from "./pages/History"; import History from "./pages/History";
import Basket from "./pages/Basket/Basket"; import Cart from "./pages/Cart/Cart";
import TariffPage from "./pages/Tariffs/TariffsPage"; import TariffPage from "./pages/Tariffs/TariffsPage";
import SavedTariffs from "./pages/SavedTariffs"; import SavedTariffs from "./pages/SavedTariffs";
import lightTheme from "@utils/themes/light"; import lightTheme from "@utils/themes/light";
@ -24,13 +24,14 @@ import Layout from "./components/Layout";
import { clearUserData, setUser, setUserAccount, useUserStore } from "./stores/user"; import { clearUserData, setUser, setUserAccount, useUserStore } from "./stores/user";
import TariffConstructor from "./pages/TariffConstructor/TariffConstructor"; import TariffConstructor from "./pages/TariffConstructor/TariffConstructor";
import { useUser } from "./utils/hooks/useUser"; import { useUser } from "./utils/hooks/useUser";
import { clearAuthToken, getMessageFromFetchError } from "@frontend/kitui"; import { clearAuthToken, getMessageFromFetchError, usePrivilegeFetcher } from "@frontend/kitui";
import { useUserAccount } from "./utils/hooks/useUserAccount"; import { useUserAccount } from "./utils/hooks/useUserAccount";
import { setCustomTariffs } from "@root/stores/customTariffs"; import { setCustomTariffs } from "@root/stores/customTariffs";
import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs"; import { useCustomTariffs } from "@root/utils/hooks/useCustomTariffs";
import { useDiscounts } from "./utils/hooks/useDiscounts"; import { useDiscounts } from "./utils/hooks/useDiscounts";
import { setDiscounts } from "./stores/discounts"; import { setDiscounts } from "./stores/discounts";
import { pdfjs } from "react-pdf"; import { pdfjs } from "react-pdf";
import { setPrivileges } from "./stores/privileges";
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.js", import.meta.url).toString(); pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.js", import.meta.url).toString();
@ -85,6 +86,13 @@ const App = () => {
} }
}); });
usePrivilegeFetcher({
onSuccess: setPrivileges,
onError: error => {
console.log("usePrivilegeFetcher error :>> ", error);
}
});
if (location.state?.redirectTo) return <Navigate to={location.state.redirectTo} replace state={{ backgroundLocation: location }} />; if (location.state?.redirectTo) return <Navigate to={location.state.redirectTo} replace state={{ backgroundLocation: location }} />;
return ( return (
@ -108,7 +116,7 @@ const App = () => {
<Route path="/support" element={<Support />} /> <Route path="/support" element={<Support />} />
<Route path="/support/:ticketId" element={<Support />} /> <Route path="/support/:ticketId" element={<Support />} />
<Route path="/tariffconstructor" element={<TariffConstructor />} /> <Route path="/tariffconstructor" element={<TariffConstructor />} />
<Route path="/cart" element={<Basket />} /> <Route path="/cart" element={<Cart />} />
<Route path="/wallet" element={<Wallet />} /> <Route path="/wallet" element={<Wallet />} />
<Route path="/payment" element={<Payment />} /> <Route path="/payment" element={<Payment />} />
<Route path="/settings" element={<AccountSettings />} /> <Route path="/settings" element={<AccountSettings />} />

@ -1,4 +1,5 @@
import { PrivilegeWithAmount, PrivilegeWithoutPrice } from "./privilege"; import { PrivilegeWithAmount } from "@frontend/kitui";
import { PrivilegeWithoutPrice } from "./privilege";
type ServiceKey = string; type ServiceKey = string;
@ -9,14 +10,9 @@ export type CustomTariffUserValuesMap = Record<ServiceKey, CustomTariffUserValue
export type ServiceKeyToPriceMap = Record<ServiceKey, number>; export type ServiceKeyToPriceMap = Record<ServiceKey, number>;
export interface CustomTariff { export interface CreateTariffBody {
name: string; name: string;
price?: number; price?: number;
isCustom: boolean; isCustom: boolean;
privilegies: PrivilegeWithAmount[]; privilegies: PrivilegeWithoutPrice[];
updatedAt?: string;
isDeleted?: boolean;
createdAt?: string;
} }
export type CreateTariffBody = Omit<CustomTariff, "privilegies"> & { privilegies: PrivilegeWithoutPrice[]; };

@ -1,21 +1,6 @@
export interface Privilege { import { Privilege, PrivilegeWithAmount } from "@frontend/kitui";
_id: string;
name: string;
privilegeId: string;
serviceKey: string;
description: string;
type: "day" | "count";
value: PrivilegeValueType;
price: number;
updatedAt?: string;
isDeleted?: boolean;
createdAt?: string;
};
export type ServiceKeyToPrivilegesMap = Record<string, Privilege[]>; export type ServiceKeyToPrivilegesMap = Record<string, Privilege[]>;
export type PrivilegeValueType = "шаблон" | "день" | "МБ";
export type PrivilegeWithAmount = Omit<Privilege, "_id"> & { amount: number; };
export type PrivilegeWithoutPrice = Omit<PrivilegeWithAmount, "price">; export type PrivilegeWithoutPrice = Omit<PrivilegeWithAmount, "price">;

@ -1,61 +0,0 @@
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import SectionWrapper from "@components/SectionWrapper";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import TotalPrice from "@components/TotalPrice";
import CustomWrapper from "./CustomWrapper";
import { useCart } from "@root/utils/hooks/useCart";
import { useCustomTariffsStore } from "@root/stores/customTariffs";
export default function Basket() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const cart = useCart();
const summaryPriceBeforeDiscountsMap = useCustomTariffsStore(state => state.summaryPriceBeforeDiscountsMap);
const summaryPriceAfterDiscountsMap = useCustomTariffsStore(state => state.summaryPriceAfterDiscountsMap);
const basePrice = Object.values(summaryPriceBeforeDiscountsMap).reduce((a, e) => a + e, 0);
const discountedPrice = Object.values(summaryPriceAfterDiscountsMap).reduce((a, e) => a + e, 0);
const totalPriceBeforeDiscounts = cart.priceBeforeDiscounts + basePrice;
const totalPriceAfterDiscounts = cart.priceAfterDiscounts + discountedPrice;
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: upMd ? "25px" : "20px",
mb: upMd ? "70px" : "37px",
}}
>
<Box
sx={{
mt: "20px",
mb: upMd ? "40px" : "20px",
display: "flex",
gap: "10px",
}}
>
{!upMd && (
<IconButton sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography component="h4" variant="h4">
Корзина
</Typography>
</Box>
<Box sx={{
mt: upMd ? "27px" : "10px",
}}>
{cart.services.map(serviceData =>
<CustomWrapper
key={serviceData.serviceKey}
serviceData={serviceData}
/>
)}
</Box>
<TotalPrice priceBeforeDiscounts={totalPriceBeforeDiscounts} priceAfterDiscounts={totalPriceAfterDiscounts} />
</SectionWrapper>
);
}

@ -1,196 +0,0 @@
import { useState } from "react";
import {
Box,
SvgIcon,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import ExpandIcon from "@components/icons/ExpandIcon";
import ClearIcon from "@mui/icons-material/Clear";
import { cardShadow } from "@root/utils/themes/shadow";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack";
import { ServiceCartData, getMessageFromFetchError } from "@frontend/kitui";
const name: Record<string, string> = {
templategen: "Шаблонизатор",
squiz: "Опросник",
reducer: "Сокращатель ссылок",
};
interface Props {
serviceData: ServiceCartData;
}
export default function CustomWrapper({ serviceData }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const [isExpanded, setIsExpanded] = useState<boolean>(false);
function handleItemDeleteClick(tariffId: string) {
removeTariffFromCart(tariffId)
.then(() => {
enqueueSnackbar("Тариф удален");
})
.catch((error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
return (
<Box
sx={{
overflow: "hidden",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<Box
sx={{
backgroundColor: "white",
"&:first-of-type": {
borderTopLeftRadius: "12px",
borderTopRightRadius: "12px",
},
"&:last-of-type": {
borderBottomLeftRadius: "12px",
borderBottomRightRadius: "12px",
},
"&:not(:last-of-type)": {
borderBottom: `1px solid ${theme.palette.grey2.main}`,
},
}}
>
<Box
onClick={() => setIsExpanded((prev) => !prev)}
sx={{
height: "72px",
px: "20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
}}
>
<Typography
sx={{
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: theme.palette.text.secondary,
px: 0,
}}
>
{name[serviceData.serviceKey]}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
height: "100%",
alignItems: "center",
gap: upSm ? "111px" : "17px",
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(serviceData.price / 100)}
</Typography>
<Box
sx={{
borderLeft: upSm ? "1px solid #9A9AAF" : "none",
paddingLeft: upSm ? "24px" : 0,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<ExpandIcon isExpanded={isExpanded} />
</Box>
</Box>
</Box>
{isExpanded &&
serviceData.privileges.map((privilege) => (
<Box
key={privilege.tariffId + privilege.privilegeId}
sx={{
px: "20px",
py: upMd ? "25px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "25px",
backgroundColor: "#F1F2F6",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "15px",
}}
>
<Typography
sx={{
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main,
}}
>
{privilege.description}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
gap: "10px",
alignItems: "center",
width: upSm ? "195px" : "123px",
marginRight: upSm ? "65px" : 0,
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(privilege.price / 100)}
</Typography>
{upSm ? (
<Typography
component="div"
onClick={() => handleItemDeleteClick(privilege.tariffId)}
sx={{
color: theme.palette.text.secondary,
borderBottom: `1px solid ${theme.palette.text.secondary}`,
width: "max-content",
lineHeight: "19px",
cursor: "pointer",
}}
>
Удалить
</Typography>
) : (
<SvgIcon
onClick={() => handleItemDeleteClick(privilege.tariffId)}
component={ClearIcon}
sx={{ fill: "#7E2AEA" }}
/>
)}
</Box>
</Box>
))}
</Box>
</Box>
);
}

67
src/pages/Cart/Cart.tsx Normal file

@ -0,0 +1,67 @@
import {
Box,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import SectionWrapper from "@components/SectionWrapper";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import TotalPrice from "@components/TotalPrice";
import CustomWrapper from "./CustomWrapper";
import { useCart } from "@root/utils/hooks/useCart";
export default function Cart() {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const cart = useCart();
const totalPriceBeforeDiscounts = cart.priceBeforeDiscounts;
const totalPriceAfterDiscounts = cart.priceAfterDiscounts;
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: upMd ? "25px" : "20px",
mb: upMd ? "70px" : "37px",
}}
>
<Box
sx={{
mt: "20px",
mb: upMd ? "40px" : "20px",
display: "flex",
gap: "10px",
}}
>
{!upMd && (
<IconButton
sx={{ p: 0, height: "28px", width: "28px", color: "black" }}
>
<ArrowBackIcon />
</IconButton>
)}
<Typography component="h4" variant="h4">
Корзина
</Typography>
</Box>
<Box
sx={{
mt: upMd ? "27px" : "10px",
}}
>
{cart.services.map((serviceData) => (
<CustomWrapper
key={serviceData.serviceKey}
serviceData={serviceData}
/>
))}
</Box>
<TotalPrice
priceBeforeDiscounts={totalPriceBeforeDiscounts}
priceAfterDiscounts={totalPriceAfterDiscounts}
/>
</SectionWrapper>
);
}

@ -0,0 +1,220 @@
import { useState } from "react";
import {
Box,
SvgIcon,
Typography,
IconButton,
useMediaQuery,
useTheme,
} from "@mui/material";
import ExpandIcon from "@components/icons/ExpandIcon";
import ClearIcon from "@mui/icons-material/Clear";
import { cardShadow } from "@root/utils/themes/shadow";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack";
import { ServiceCartData, getMessageFromFetchError } from "@frontend/kitui";
import { ReactComponent as CrossIcon } from "@root/assets/Icons/cross.svg";
import type { MouseEvent } from "react";
import CustomTariffAccordion from "@root/components/CustomTariffAccordion";
const name: Record<string, string> = {
templategen: "Шаблонизатор",
squiz: "Опросник",
reducer: "Сокращатель ссылок",
};
interface Props {
serviceData: ServiceCartData;
}
export default function CustomWrapper({ serviceData }: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const [isExpanded, setIsExpanded] = useState<boolean>(false);
function handleItemDeleteClick(tariffId: string) {
removeTariffFromCart(tariffId)
.then(() => {
enqueueSnackbar("Тариф удален");
})
.catch((error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
const deleteService = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setIsExpanded(false);
for (const { tariffId } of serviceData.tariffs) {
try {
await removeTariffFromCart(tariffId);
} catch { }
}
enqueueSnackbar("Тарифы удален");
};
return (
<Box
sx={{
overflow: "hidden",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<Box
sx={{
backgroundColor: "white",
"&:first-of-type": {
borderTopLeftRadius: "12px",
borderTopRightRadius: "12px",
},
"&:last-of-type": {
borderBottomLeftRadius: "12px",
borderBottomRightRadius: "12px",
},
"&:not(:last-of-type)": {
borderBottom: `1px solid ${theme.palette.grey2.main}`,
},
}}
>
<Box
onClick={() => setIsExpanded((prev) => !prev)}
sx={{
height: "72px",
px: "20px",
display: "flex",
gap: "15px",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
}}
>
<Box
sx={{
width: "50px",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<ExpandIcon isExpanded={isExpanded} />
</Box>
<Typography
sx={{
width: "100%",
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: theme.palette.text.secondary,
px: 0,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{name[serviceData.serviceKey]}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
height: "100%",
alignItems: "center",
gap: upSm ? "111px" : "17px",
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(serviceData.price / 100)}
</Typography>
</Box>
<IconButton
onClick={deleteService}
sx={{ padding: 0, height: "22px", width: "22px" }}
>
<CrossIcon style={{ height: "22 px", width: "22px" }} />
</IconButton>
</Box>
{isExpanded &&
serviceData.tariffs.map(tariff => {
const privilege = tariff.privileges[0];
return tariff.privileges.length > 1 ? (
<CustomTariffAccordion
key={privilege.tariffId + privilege.privilegeId}
tariffCartData={tariff}
/>
) : (
<Box
key={privilege.tariffId + privilege.privilegeId}
sx={{
px: "20px",
py: upMd ? "25px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "25px",
backgroundColor: "#F1F2F6",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "15px",
}}
>
<Typography
sx={{
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main,
width: "100%",
}}
>
{privilege.description}
</Typography>
<Box
sx={{
width: upSm ? "140px" : "123px",
marginRight: upSm ? "65px" : 0,
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: upSm ? "20px" : "16px",
fontWeight: 500,
}}
>
{currencyFormatter.format(tariff.price / 100)}
</Typography>
</Box>
<Box
sx={{
cursor: "pointer",
width: "35px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleItemDeleteClick(privilege.tariffId)}
>
<SvgIcon component={ClearIcon} sx={{ fill: "#7E2AEA" }} />
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

@ -6,12 +6,12 @@ import {
IconButton, IconButton,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { Link, useParams } from "react-router-dom"; import { Link, useParams, useLocation } from "react-router-dom";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import SupportChat from "./SupportChat"; import SupportChat from "./SupportChat";
import CreateTicket from "./CreateTicket"; import CreateTicket from "./CreateTicket";
import TicketList from "./TicketList/TicketList"; import TicketList from "./TicketList/TicketList";
import { useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { Ticket, getMessageFromFetchError, useToken } from "@frontend/kitui"; import { Ticket, getMessageFromFetchError, useToken } from "@frontend/kitui";
import { import {
updateTickets, updateTickets,
@ -23,12 +23,14 @@ import { enqueueSnackbar } from "notistack";
import { useSSESubscription, useTickets } from "@frontend/kitui"; import { useSSESubscription, useTickets } from "@frontend/kitui";
export default function Support() { export default function Support() {
const [previousPage, setPreviousPage] = useState<string>("");
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const ticketId = useParams().ticketId; const ticketId = useParams().ticketId;
const ticketApiPage = useTicketStore((state) => state.apiPage); const ticketApiPage = useTicketStore((state) => state.apiPage);
const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage); const ticketsPerPage = useTicketStore((state) => state.ticketsPerPage);
const token = useToken(); const token = useToken();
const location = useLocation();
const fetchState = useTickets({ const fetchState = useTickets({
url: "https://hub.pena.digital/heruvym/getTickets", url: "https://hub.pena.digital/heruvym/getTickets",
@ -44,6 +46,10 @@ export default function Support() {
}, []), }, []),
}); });
useEffect(() => {
setPreviousPage(location.state?.previousUrl || "/");
}, []);
useSSESubscription<Ticket>({ useSSESubscription<Ticket>({
enabled: Boolean(token), enabled: Boolean(token),
url: `https://admin.pena.digital/heruvym/subscribe?Authorization=${token}`, url: `https://admin.pena.digital/heruvym/subscribe?Authorization=${token}`,
@ -73,7 +79,7 @@ export default function Support() {
}} }}
> >
<Link <Link
to="/support" to={ticketId ? "/support" : previousPage}
style={{ style={{
textDecoration: "none", textDecoration: "none",
display: "flex", display: "flex",

@ -1,144 +1,145 @@
import { import {
Box, Box,
Divider, Divider,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import CustomButton from "../../components/CustomButton"; import CustomButton from "../../components/CustomButton";
import { Privilege } from "@root/model/privilege";
import TariffPrivilegeSlider from "./TariffItem"; import TariffPrivilegeSlider from "./TariffItem";
import { import {
createAndSendTariff, createAndSendTariff,
useCustomTariffsStore, useCustomTariffsStore,
} from "@root/stores/customTariffs"; } from "@root/stores/customTariffs";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { devlog, getMessageFromFetchError } from "@frontend/kitui"; import { Privilege, getMessageFromFetchError } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { updateTariffs } from "@root/stores/tariffs";
import { addTariffToCart } from "@root/stores/user";
interface Props { interface Props {
serviceKey: string; serviceKey: string;
privileges: Privilege[]; privileges: Privilege[];
} }
export default function CustomTariffCard({ serviceKey, privileges }: Props) { export default function CustomTariffCard({ serviceKey, privileges }: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const summaryPriceBeforeDiscounts = useCustomTariffsStore( const summaryPriceBeforeDiscounts = useCustomTariffsStore(
(state) => state.summaryPriceBeforeDiscountsMap (state) => state.summaryPriceBeforeDiscountsMap
); );
const summaryPriceAfterDiscounts = useCustomTariffsStore( const summaryPriceAfterDiscounts = useCustomTariffsStore(
(state) => state.summaryPriceAfterDiscountsMap (state) => state.summaryPriceAfterDiscountsMap
); );
const priceBeforeDiscounts = summaryPriceBeforeDiscounts[serviceKey] ?? 0; const priceBeforeDiscounts = summaryPriceBeforeDiscounts[serviceKey] ?? 0;
const priceAfterDiscounts = summaryPriceAfterDiscounts[serviceKey] ?? 0; const priceAfterDiscounts = summaryPriceAfterDiscounts[serviceKey] ?? 0;
async function handleConfirmClick() { async function handleConfirmClick() {
createAndSendTariff(serviceKey) try {
.then((result) => { const tariff = await createAndSendTariff(serviceKey);
devlog(result); updateTariffs([tariff]);
enqueueSnackbar("Тариф создан"); await addTariffToCart(tariff._id);
}) enqueueSnackbar("Тариф добавлен в корзину");
.catch((error) => { } catch (error) {
const message = getMessageFromFetchError( const message = getMessageFromFetchError(
error, error,
"Не удалось создать тариф" "Не удалось создать тариф"
); );
if (message) enqueueSnackbar(message); if (message) enqueueSnackbar(message);
}); }
} }
return ( return (
<Box
sx={{
backgroundColor: "white",
width: "100%",
display: "flex",
flexDirection: upMd ? "row" : "column",
borderRadius: "12px",
boxShadow: cardShadow,
}}
>
<Box
sx={{
p: "20px",
pr: upMd ? "35px" : undefined,
display: "flex",
flexBasis: 0,
flexGrow: 2.37,
flexWrap: "wrap",
flexDirection: "column",
gap: "25px",
}}
>
{privileges.map((privilege) => (
<TariffPrivilegeSlider key={privilege._id} privilege={privilege} />
))}
</Box>
{!upMd && (
<Divider
sx={{ mx: "20px", my: "10px", borderColor: theme.palette.grey2.main }}
/>
)}
<Box
sx={{
display: "flex",
flexBasis: 0,
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "start",
color: theme.palette.grey3.main,
p: "20px",
pl: upMd ? "33px" : undefined,
borderLeft: upMd
? `1px solid ${theme.palette.grey2.main}`
: undefined,
}}
>
<Box <Box
sx={{ sx={{
display: "flex", backgroundColor: "white",
justifyContent: "space-between", width: "100%",
gap: "15%", display: "flex",
mb: "auto", flexDirection: upMd ? "row" : "column",
width: "100%", borderRadius: "12px",
}} boxShadow: cardShadow,
}}
> >
<Typography> <Box
Чем больше пакеты, тем дешевле подписки и опции{" "} sx={{
</Typography> p: "20px",
pr: upMd ? "35px" : undefined,
display: "flex",
flexBasis: 0,
flexGrow: 2.37,
flexWrap: "wrap",
flexDirection: "column",
gap: "25px",
}}
>
{privileges.map((privilege) => (
<TariffPrivilegeSlider key={privilege._id} privilege={privilege} />
))}
</Box>
{!upMd && (
<Divider
sx={{ mx: "20px", my: "10px", borderColor: theme.palette.grey2.main }}
/>
)}
<Box
sx={{
display: "flex",
flexBasis: 0,
flexGrow: 1,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "start",
color: theme.palette.grey3.main,
p: "20px",
pl: upMd ? "33px" : undefined,
borderLeft: upMd
? `1px solid ${theme.palette.grey2.main}`
: undefined,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
gap: "15%",
mb: "auto",
width: "100%",
}}
>
<Typography>
Чем больше пакеты, тем дешевле подписки и опции{" "}
</Typography>
</Box>
<Typography mb="20px" mt="18px">
Сумма с учетом скидки
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
mb: "30px",
}}
>
<Typography variant="price">
{currencyFormatter.format(priceAfterDiscounts / 100)}
</Typography>
<Typography variant="oldPrice" pt="3px">
{currencyFormatter.format(priceBeforeDiscounts / 100)}
</Typography>
</Box>
<CustomButton
variant="contained"
onClick={handleConfirmClick}
sx={{
backgroundColor: theme.palette.brightPurple.main,
}}
>
Выбрать
</CustomButton>
</Box>
</Box> </Box>
<Typography mb="20px" mt="18px"> );
Сумма с учетом скидки
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
mb: "30px",
}}
>
<Typography variant="price">
{currencyFormatter.format(priceAfterDiscounts / 100)}
</Typography>
<Typography variant="oldPrice" pt="3px">
{currencyFormatter.format(priceBeforeDiscounts / 100)}
</Typography>
</Box>
<CustomButton
variant="contained"
onClick={handleConfirmClick}
sx={{
backgroundColor: theme.palette.brightPurple.main,
}}
>
Выбрать
</CustomButton>
</Box>
</Box>
);
} }

@ -7,12 +7,16 @@ import CustomTariffCard from "./CustomTariffCard";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import TotalPrice from "@root/components/TotalPrice"; import TotalPrice from "@root/components/TotalPrice";
import { serviceNameByKey } from "@root/utils/serviceKeys"; import { serviceNameByKey } from "@root/utils/serviceKeys";
import { useAllTariffsFetcher } from "@root/utils/hooks/useAllTariffsFetcher";
import { updateTariffs } from "@root/stores/tariffs";
import { getMessageFromFetchError } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
export default function TariffConstructor() { export default function TariffConstructor() {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const customTariffs = useCustomTariffsStore( const customTariffs = useCustomTariffsStore(
(state) => state.customTariffsMap (state) => state.privilegeByService
); );
const summaryPriceBeforeDiscountsMap = useCustomTariffsStore( const summaryPriceBeforeDiscountsMap = useCustomTariffsStore(
(state) => state.summaryPriceBeforeDiscountsMap (state) => state.summaryPriceBeforeDiscountsMap
@ -30,6 +34,14 @@ export default function TariffConstructor() {
0 0
); );
useAllTariffsFetcher({
onSuccess: updateTariffs,
onError: (error) => {
const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage);
},
});
return ( return (
<SectionWrapper <SectionWrapper
maxWidth="lg" maxWidth="lg"

@ -1,4 +1,4 @@
import { useThrottle } from "@frontend/kitui"; import { Privilege, PrivilegeValueType, useThrottle } from "@frontend/kitui";
import { import {
Box, Box,
SliderProps, SliderProps,
@ -10,7 +10,6 @@ import { CustomSlider } from "@root/components/CustomSlider";
import NumberInputWithUnitAdornment from "@root/components/NumberInputWithUnitAdornment"; import NumberInputWithUnitAdornment from "@root/components/NumberInputWithUnitAdornment";
import CalendarIcon from "@root/components/icons/CalendarIcon"; import CalendarIcon from "@root/components/icons/CalendarIcon";
import PieChartIcon from "@root/components/icons/PieChartIcon"; import PieChartIcon from "@root/components/icons/PieChartIcon";
import { Privilege, PrivilegeValueType } from "@root/model/privilege";
import { useCartStore } from "@root/stores/cart"; import { useCartStore } from "@root/stores/cart";
import { import {
setCustomTariffsUserValue, setCustomTariffsUserValue,

@ -18,7 +18,13 @@ export default function FreeTariffCard() {
sx={{ sx={{
backgroundColor: "#7E2AEA", backgroundColor: "#7E2AEA",
color: "white", color: "white",
minHeight: "206px", }}
buttonProps={{
text: "Выбрать",
sx: {
color: "white",
borderColor: "white",
},
}} }}
/> />
); );

@ -1,148 +1,124 @@
import { import {
Box, Box,
Typography, Typography,
Tooltip, Tooltip,
SxProps, SxProps,
Theme, Theme,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import CustomButton from "@components/CustomButton"; import CustomButton from "@components/CustomButton";
import { MouseEventHandler, ReactNode } from "react"; import { MouseEventHandler, ReactNode } from "react";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
interface Props { interface Props {
icon: ReactNode; icon: ReactNode;
headerText: string; headerText: string;
text: string | string[]; text: string | string[];
sx?: SxProps<Theme>;
buttonProps?: {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
onClick?: MouseEventHandler<HTMLButtonElement>; buttonProps?: {
text?: string; sx?: SxProps<Theme>;
}; onClick?: MouseEventHandler<HTMLButtonElement>;
price?: ReactNode; text?: string;
};
price?: ReactNode;
} }
export default function TariffCard({ export default function TariffCard({
icon, icon,
headerText, headerText,
text, text,
sx, sx,
price, price,
buttonProps, buttonProps,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
return ( text = Array.isArray(text) ? text : [text];
<Box
sx={{ return (
width: "100%",
bgcolor: "white",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
alignItems: "start",
p: "20px",
boxShadow: cardShadow,
...sx,
}}
>
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "16px",
}}
>
{icon}
{price && (
<Box
sx={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
columnGap: "10px",
rowGap: 0,
}}
>
{price}
</Box>
)}
</Box>
<Tooltip title={headerText} placement="top">
<Typography
variant="h5"
sx={{
mt: "14px",
mb: "10px",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
width: "100%",
}}
>
{headerText}
</Typography>
</Tooltip>
{Array.isArray(text) ? (
<Box <Box
sx={{
marginBottom: "auto",
}}
>
{text.map((line, index) => (
<Tooltip key={index} title={line} placement="top">
<Typography
sx={{
height: "65px",
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
MozBoxOrient: "vertical",
WebkitLineClamp: 3,
}}
>
{line}
</Typography>
</Tooltip>
))}
</Box>
) : (
<Tooltip title={text} placement="top">
<Typography
sx={{ sx={{
minHeight: "calc(1.185*2em)", width: "100%",
marginBottom: "auto", minHeight: "250px",
height: "65px", bgcolor: "white",
overflow: "hidden", borderRadius: "12px",
textOverflow: "ellipsis", display: "flex",
display: "-webkit-box", flexDirection: "column",
WebkitBoxOrient: "vertical", alignItems: "start",
MozBoxOrient: "vertical", p: "20px",
WebkitLineClamp: 3, boxShadow: cardShadow,
...sx,
}} }}
>
{text}
</Typography>
</Tooltip>
)}
{buttonProps && (
<CustomButton
onClick={buttonProps.onClick}
variant="outlined"
sx={{
color: theme.palette.brightPurple.main,
borderColor: theme.palette.brightPurple.main,
mt: "10px",
...buttonProps.sx,
}}
> >
{buttonProps.text} <Box
</CustomButton> sx={{
)} width: "100%",
</Box> display: "flex",
); justifyContent: "space-between",
alignItems: "center",
gap: "16px",
}}
>
{icon}
{price && (
<Box
sx={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
columnGap: "10px",
rowGap: 0,
}}
>
{price}
</Box>
)}
</Box>
<Tooltip title={<Typography>{headerText}</Typography>} placement="top">
<Typography
variant="h5"
sx={{
mt: "14px",
mb: "10px",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
width: "100%",
}}
>
{headerText}
</Typography>
</Tooltip>
<Tooltip
title={text.map((line, index) => (
<Typography key={index}>{line}</Typography>
))}
placement="top"
>
<Box sx={{
overflow: "hidden",
textOverflow: "clip",
mb: "auto",
}}>
{text.map((line, index) => (
<Typography key={index}>{line}</Typography>
))}
</Box>
</Tooltip>
{buttonProps && (
<CustomButton
onClick={buttonProps.onClick}
variant="outlined"
sx={{
color: theme.palette.brightPurple.main,
borderColor: theme.palette.brightPurple.main,
mt: "10px",
...buttonProps.sx,
}}
>
{buttonProps.text}
</CustomButton>
)}
</Box>
);
} }

@ -2,7 +2,6 @@ import { useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import { useTariffs } from "@root/utils/hooks/useTariffs";
import { updateTariffs, useTariffStore } from "@root/stores/tariffs"; import { updateTariffs, useTariffStore } from "@root/stores/tariffs";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { Select } from "@root/components/Select"; import { Select } from "@root/components/Select";
@ -11,154 +10,147 @@ import TariffCard from "./TariffCard";
import NumberIcon from "@root/components/NumberIcon"; import NumberIcon from "@root/components/NumberIcon";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { calcIndividualTariffPrices } from "@root/utils/calcTariffPrices"; import { calcIndividualTariffPrices } from "@root/utils/calcTariffPrices";
import { getMessageFromFetchError } from "@frontend/kitui"; import { Tariff, getMessageFromFetchError } from "@frontend/kitui";
import FreeTariffCard from "./FreeTariffCard"; import FreeTariffCard from "./FreeTariffCard";
import { addTariffToCart, useUserStore } from "@root/stores/user"; import { addTariffToCart, useUserStore } from "@root/stores/user";
import { useDiscountStore } from "@root/stores/discounts"; import { useDiscountStore } from "@root/stores/discounts";
import { useCustomTariffsStore } from "@root/stores/customTariffs";
import { Slider } from "./slider"; import { Slider } from "./slider";
import { useCartStore } from "@root/stores/cart"; import { useCartStore } from "@root/stores/cart";
import { useAllTariffsFetcher } from "@root/utils/hooks/useAllTariffsFetcher";
const subPages = ["Шаблонизатор", "Опросник", "Сокращатель ссылок"]; const subPages = ["Шаблонизатор", "Опросник", "Сокращатель ссылок"];
export default function TariffPage() { const StepperText: Record<string, string> = {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const location = useLocation();
const tariffs = useTariffStore((state) => state.tariffs);
const [selectedItem, setSelectedItem] = useState<number>(0);
const discounts = useDiscountStore((state) => state.discounts);
const customTariffs = useCustomTariffsStore(
(state) => state.customTariffsMap
);
const purchasesAmount =
useUserStore((state) => state.userAccount?.wallet.purchasesAmount) ?? 0;
const cart = useCartStore((state) => state.cart);
const unit: string = String(location.pathname).slice(9);
const StepperText: Record<string, string> = {
volume: "Тарифы на объём", volume: "Тарифы на объём",
time: "Тарифы на время", time: "Тарифы на время",
}; };
useTariffs({ export default function TariffPage() {
apiPage: 0, const theme = useTheme();
tariffsPerPage: 100, const upMd = useMediaQuery(theme.breakpoints.up("md"));
onNewTariffs: updateTariffs, const isMobile = useMediaQuery(theme.breakpoints.down(600));
onError: (error) => { const location = useLocation();
const errorMessage = getMessageFromFetchError(error); const tariffs = useTariffStore((state) => state.tariffs);
if (errorMessage) enqueueSnackbar(errorMessage); const [selectedItem, setSelectedItem] = useState<number>(0);
}, const discounts = useDiscountStore((state) => state.discounts);
}); const purchasesAmount = useUserStore((state) => state.userAccount?.wallet.purchasesAmount) ?? 0;
const cartTariffMap = useCartStore((state) => state.cartTariffMap);
function handleTariffItemClick(tariffId: string) { const unit: string = String(location.pathname).slice(9);
addTariffToCart(tariffId) const currentTariffs = Object.values(cartTariffMap).filter((tariff): tariff is Tariff => typeof tariff === "object");
.then(() => {
enqueueSnackbar("Тариф добавлен в корзину");
})
.catch((error) => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
const filteredTariffs = tariffs.filter((tariff) => { useAllTariffsFetcher({
return ( onSuccess: updateTariffs,
tariff.privilegies.map((p) => p.type).includes("day") === onError: (error) => {
(unit === "time") && !tariff.isDeleted const errorMessage = getMessageFromFetchError(error);
); if (errorMessage) enqueueSnackbar(errorMessage);
}); },
});
const tariffElements = filteredTariffs.map((tariff, index) => { function handleTariffItemClick(tariffId: string) {
const { price, tariffPriceAfterDiscounts } = calcIndividualTariffPrices( addTariffToCart(tariffId)
tariff, .then(() => {
discounts, enqueueSnackbar("Тариф добавлен в корзину");
customTariffs, })
purchasesAmount, .catch((error) => {
cart const message = getMessageFromFetchError(error);
); if (message) enqueueSnackbar(message);
});
}
const filteredTariffs = tariffs.filter((tariff) => {
return (
tariff.privilegies.map((p) => p.type).includes("day") ===
(unit === "time") && !tariff.isDeleted
);
});
const tariffElements = filteredTariffs.filter((tariff)=>tariff.privilegies.length > 0).map((tariff, index) => {
const { priceBeforeDiscounts, priceAfterDiscounts } = calcIndividualTariffPrices(
tariff,
discounts,
purchasesAmount,
currentTariffs,
);
return (
<TariffCard
key={tariff._id}
icon={
<NumberIcon
number={index + 1}
color={unit === "time" ? "#7E2AEA" : "#FB5607"}
backgroundColor={unit === "time" ? "#EEE4FC" : "#FEDFD0"}
/>
}
buttonProps={{
text: "Выбрать",
onClick: () => handleTariffItemClick(tariff._id),
}}
headerText={tariff.name}
text={tariff.privilegies.map((p) => `${p.name} - ${p.amount}`)}
price={
<>
{priceBeforeDiscounts !== priceAfterDiscounts && (
<Typography variant="oldPrice">
{currencyFormatter.format(priceBeforeDiscounts / 100)}
</Typography>
)}
<Typography variant="price">
{currencyFormatter.format(priceAfterDiscounts / 100)}
</Typography>
</>
}
/>
);
});
if (tariffElements.length < 6)
tariffElements.push(<FreeTariffCard key="free_tariff_card" />);
else tariffElements.splice(5, 0, <FreeTariffCard key="free_tariff_card" />);
return ( return (
<TariffCard <SectionWrapper
key={tariff._id} maxWidth="lg"
icon={ sx={{
<NumberIcon mt: "20px",
number={index + 1} mb: upMd ? "90px" : "63px",
color={unit === "time" ? "#7E2AEA" : "#FB5607"} display: "flex",
backgroundColor={unit === "time" ? "#EEE4FC" : "#FEDFD0"} flexDirection: "column",
/> }}
} >
buttonProps={{ <Typography variant="h4" sx={{ marginBottom: "23px", mt: "20px" }}>
text: "Выбрать", {StepperText[unit]}
onClick: () => handleTariffItemClick(tariff._id), </Typography>
}} {isMobile ? (
headerText={tariff.name} <Select
text={tariff.privilegies.map((p) => `${p.name} - ${p.amount}`)} items={subPages}
price={ selectedItem={selectedItem}
<> setSelectedItem={setSelectedItem}
{price !== undefined && price !== tariffPriceAfterDiscounts && ( />
<Typography variant="oldPrice"> ) : (
{currencyFormatter.format(price / 100)} <Tabs
</Typography> items={subPages}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)} )}
{tariffPriceAfterDiscounts !== undefined && ( <Box
<Typography variant="price"> sx={{
{currencyFormatter.format(tariffPriceAfterDiscounts / 100)} justifyContent: "center",
</Typography> mt: "40px",
)} mb: "30px",
</> display: "grid",
} gap: "40px",
/> gridTemplateColumns: "repeat(auto-fit, minmax(300px, 360px))",
}}
>
{tariffElements}
</Box>
<Typography variant="h4" sx={{ mt: "40px" }}>
Ранее вы покупали
</Typography>
<Slider items={tariffElements} />
</SectionWrapper>
); );
});
if (tariffElements.length < 6)
tariffElements.push(<FreeTariffCard key="free_tariff_card" />);
else tariffElements.splice(5, 0, <FreeTariffCard key="free_tariff_card" />);
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: "20px",
mb: upMd ? "90px" : "63px",
display: "flex",
flexDirection: "column",
}}
>
<Typography variant="h4" sx={{ marginBottom: "23px", mt: "20px" }}>
{StepperText[unit]}
</Typography>
{isMobile ? (
<Select
items={subPages}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
) : (
<Tabs
items={subPages}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)}
<Box
sx={{
justifyContent: "center",
mt: "40px",
mb: "30px",
display: "grid",
gap: "40px",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 360px))",
}}
>
{tariffElements}
</Box>
<Typography variant="h4" sx={{ mt: "40px" }}>
Ранее вы покупали
</Typography>
<Slider items={tariffElements} />
</SectionWrapper>
);
} }

@ -1,21 +1,21 @@
import { createTariff } from "@root/api/tariff"; import { createTariff } from "@root/api/tariff";
import { CustomTariffUserValuesMap, ServiceKeyToPriceMap } from "@root/model/customTariffs"; import { CustomTariffUserValuesMap, ServiceKeyToPriceMap } from "@root/model/customTariffs";
import { ServiceKeyToPrivilegesMap, PrivilegeWithoutPrice } from "@root/model/privilege"; import { ServiceKeyToPrivilegesMap } from "@root/model/privilege";
import { produce } from "immer"; import { produce } from "immer";
import { create } from "zustand"; import { create } from "zustand";
import { devtools, persist } from "zustand/middleware"; import { devtools, persist } from "zustand/middleware";
import { Discount, findCartDiscount, findLoyaltyDiscount, findPrivilegeDiscount, findServiceDiscount } from "@frontend/kitui"; import { Discount, PrivilegeWithAmount, findCartDiscount, findDiscountFactor, findLoyaltyDiscount, findPrivilegeDiscount, findServiceDiscount } from "@frontend/kitui";
interface CustomTariffsStore { interface CustomTariffsStore {
customTariffsMap: ServiceKeyToPrivilegesMap; privilegeByService: ServiceKeyToPrivilegesMap;
userValuesMap: CustomTariffUserValuesMap; userValuesMap: CustomTariffUserValuesMap;
summaryPriceBeforeDiscountsMap: ServiceKeyToPriceMap; summaryPriceBeforeDiscountsMap: ServiceKeyToPriceMap;
summaryPriceAfterDiscountsMap: ServiceKeyToPriceMap; summaryPriceAfterDiscountsMap: ServiceKeyToPriceMap;
} }
const initialState: CustomTariffsStore = { const initialState: CustomTariffsStore = {
customTariffsMap: {}, privilegeByService: {},
userValuesMap: {}, userValuesMap: {},
summaryPriceBeforeDiscountsMap: {}, summaryPriceBeforeDiscountsMap: {},
summaryPriceAfterDiscountsMap: {}, summaryPriceAfterDiscountsMap: {},
@ -41,7 +41,7 @@ export const useCustomTariffsStore = create<CustomTariffsStore>()(
) )
); );
export const setCustomTariffs = (customTariffs: ServiceKeyToPrivilegesMap) => useCustomTariffsStore.setState({ customTariffsMap: customTariffs }); export const setCustomTariffs = (customTariffs: ServiceKeyToPrivilegesMap) => useCustomTariffsStore.setState({ privilegeByService: customTariffs });
export const setCustomTariffsUserValue = ( export const setCustomTariffsUserValue = (
serviceKey: string, serviceKey: string,
@ -58,22 +58,22 @@ export const setCustomTariffsUserValue = (
let priceWithoutDiscounts = 0; let priceWithoutDiscounts = 0;
let priceAfterDiscounts = 0; let priceAfterDiscounts = 0;
state.customTariffsMap[serviceKey].forEach(tariff => { state.privilegeByService[serviceKey].forEach(privilege => {
const amount = state.userValuesMap[serviceKey]?.[tariff._id] ?? 0; const amount = state.userValuesMap[serviceKey]?.[privilege._id] ?? 0;
priceWithoutDiscounts += tariff.price * amount; priceWithoutDiscounts += privilege.price * amount;
const discount = findPrivilegeDiscount(tariff.privilegeId, tariff.price * amount, discounts); const discount = findPrivilegeDiscount(privilege.privilegeId, privilege.price * amount, discounts);
priceAfterDiscounts += tariff.price * amount * discount.factor; priceAfterDiscounts += privilege.price * amount * findDiscountFactor(discount);
}); });
const serviceDiscount = findServiceDiscount(serviceKey, priceAfterDiscounts, discounts); const serviceDiscount = findServiceDiscount(serviceKey, priceAfterDiscounts, discounts);
priceAfterDiscounts *= serviceDiscount.factor; priceAfterDiscounts *= findDiscountFactor(serviceDiscount);
const cartDiscount = findCartDiscount(currentCartTotal, discounts); const cartDiscount = findCartDiscount(currentCartTotal, discounts);
priceAfterDiscounts *= cartDiscount.factor; priceAfterDiscounts *= findDiscountFactor(cartDiscount);
const loyaltyDiscount = findLoyaltyDiscount(purchasesAmount, discounts); const loyaltyDiscount = findLoyaltyDiscount(purchasesAmount, discounts);
priceAfterDiscounts *= loyaltyDiscount.factor; priceAfterDiscounts *= findDiscountFactor(loyaltyDiscount);
state.summaryPriceBeforeDiscountsMap[serviceKey] = priceWithoutDiscounts; state.summaryPriceBeforeDiscountsMap[serviceKey] = priceWithoutDiscounts;
state.summaryPriceAfterDiscountsMap[serviceKey] = priceAfterDiscounts; state.summaryPriceAfterDiscountsMap[serviceKey] = priceAfterDiscounts;
@ -83,22 +83,21 @@ export const setCustomTariffsUserValue = (
export const createAndSendTariff = (serviceKey: string) => { export const createAndSendTariff = (serviceKey: string) => {
const state = useCustomTariffsStore.getState(); const state = useCustomTariffsStore.getState();
const privilegies: PrivilegeWithoutPrice[] = []; const privilegies: PrivilegeWithAmount[] = [];
Object.entries(state.userValuesMap[serviceKey]).forEach(([privilegeId, userValue]) => { Object.entries(state.userValuesMap[serviceKey]).forEach(([privilegeId, userValue]) => {
if (userValue === 0) return; if (userValue === 0) return;
const privilege = state.customTariffsMap[serviceKey].find(p => p._id === privilegeId); const privilegeWithoutAmount = state.privilegeByService[serviceKey].find(p => p._id === privilegeId);
if (!privilege) throw new Error(`Privilege not found: ${privilegeId}`); if (!privilegeWithoutAmount) throw new Error(`Privilege not found: ${privilegeId}`);
const p2 = { const privilege: PrivilegeWithAmount = {
...privilege, ...privilegeWithoutAmount,
privilegeId: privilege._id, privilegeId: privilegeWithoutAmount._id,
amount: userValue, amount: userValue,
} as PrivilegeWithoutPrice; };
delete (p2 as any).price;
privilegies.push(p2); privilegies.push(privilege);
}); });
const name = [...privilegies.map(p => p.name), new Date().toISOString()].join(", "); const name = [...privilegies.map(p => p.name), new Date().toISOString()].join(", ");

24
src/stores/privileges.ts Normal file

@ -0,0 +1,24 @@
import { PrivilegeWithAmount } from "@frontend/kitui";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface PrivilegeStore {
privileges: PrivilegeWithAmount[];
}
const initialState: PrivilegeStore = {
privileges: [],
};
const usePrivilegeStore = create<PrivilegeStore>()(
devtools(
(get, set) => initialState,
{
name: "Privileges",
enabled: process.env.NODE_ENV === "development",
}
)
);
export const setPrivileges = (privileges: PrivilegeStore["privileges"]) => usePrivilegeStore.setState({ privileges });

@ -33,7 +33,7 @@ export const updateTariffs = (tariffs: TariffStore["tariffs"]) => useTariffStore
false, false,
{ {
type: "updateTariffs", type: "updateTariffs",
tariffsLength: tariffs.length, tariffs: tariffs,
} }
); );

@ -1,3 +1,4 @@
// @ts-nocheck TODO fix tests
import { CartData, Discount, Tariff } from "@frontend/kitui"; import { CartData, Discount, Tariff } from "@frontend/kitui";
import { calcCart } from "./calcCart"; import { calcCart } from "./calcCart";

@ -1,4 +1,4 @@
import { CartData, Discount, PrivilegeCartData, Tariff, applyCartDiscount, applyLoyaltyDiscount, applyPrivilegeDiscounts, applyServiceDiscounts } from "@frontend/kitui"; import { CartData, Discount, PrivilegeCartData, Tariff, TariffCartData, applyCartDiscount, applyLoyaltyDiscount, applyPrivilegeDiscounts, applyServiceDiscounts } from "@frontend/kitui";
export function calcCart(tariffs: Tariff[], discounts: Discount[], purchasesAmount: number): CartData { export function calcCart(tariffs: Tariff[], discounts: Discount[], purchasesAmount: number): CartData {
@ -13,25 +13,31 @@ export function calcCart(tariffs: Tariff[], discounts: Discount[], purchasesAmou
}; };
tariffs.forEach(tariff => { tariffs.forEach(tariff => {
if (tariff.price && tariff.price > 0) cartData.priceBeforeDiscounts += tariff.price; let serviceData = cartData.services.find(service => service.serviceKey === tariff.privilegies[0].serviceKey);
if (!serviceData) {
serviceData = {
serviceKey: tariff.privilegies[0].serviceKey,
tariffs: [],
price: 0,
appliedServiceDiscount: null,
};
cartData.services.push(serviceData);
}
const tariffCartData: TariffCartData = {
price: tariff.price ?? 0,
isCustom: tariff.isCustom,
privileges: [],
tariffId: tariff._id,
};
serviceData.tariffs.push(tariffCartData);
tariff.privilegies.forEach(privilege => { tariff.privilegies.forEach(privilege => {
let serviceData = cartData.services.find(service => service.serviceKey === privilege.serviceKey);
if (!serviceData) {
serviceData = {
serviceKey: privilege.serviceKey,
privileges: [],
price: 0,
appliedServiceDiscount: null,
};
cartData.services.push(serviceData);
}
const privilegePrice = privilege.amount * privilege.price; const privilegePrice = privilege.amount * privilege.price;
if (!tariff.price) cartData.priceBeforeDiscounts += privilegePrice; if (!tariff.price) tariffCartData.price += privilegePrice;
const privilegeData: PrivilegeCartData = { const privilegeCartData: PrivilegeCartData = {
tariffId: tariff._id, tariffId: tariff._id,
serviceKey: privilege.serviceKey, serviceKey: privilege.serviceKey,
privilegeId: privilege.privilegeId, privilegeId: privilege.privilegeId,
@ -41,11 +47,13 @@ export function calcCart(tariffs: Tariff[], discounts: Discount[], purchasesAmou
tariffName: tariff.name, tariffName: tariff.name,
}; };
serviceData.privileges.push(privilegeData); tariffCartData.privileges.push(privilegeCartData);
serviceData.price += privilegePrice;
cartData.priceAfterDiscounts += privilegePrice; cartData.priceAfterDiscounts += privilegePrice;
cartData.itemCount++; cartData.itemCount++;
}); });
cartData.priceBeforeDiscounts += tariffCartData.price;
serviceData.price += tariffCartData.price;
}); });
applyPrivilegeDiscounts(cartData, discounts); applyPrivilegeDiscounts(cartData, discounts);

@ -1,47 +1,24 @@
import { ServiceKeyToPrivilegesMap } from "@root/model/privilege"; import { Discount, Tariff, findDiscountFactor } from "@frontend/kitui";
import { CartData, Discount, Tariff, findCartDiscount, findLoyaltyDiscount, findPrivilegeDiscount, findServiceDiscount } from "@frontend/kitui"; import { calcCart } from "./calcCart/calcCart";
export function calcIndividualTariffPrices( export function calcIndividualTariffPrices(
tariff: Tariff, tariff: Tariff,
discounts: Discount[], discounts: Discount[],
privilegies: ServiceKeyToPrivilegesMap,
purchasesAmount: number, purchasesAmount: number,
cart: CartData, currentTariffs: Tariff[],
): { ): {
price: number | undefined; priceBeforeDiscounts: number;
tariffPriceAfterDiscounts: number | undefined; priceAfterDiscounts: number;
} { } {
let price = tariff.price || tariff.privilegies.reduce((sum, privilege) => sum + privilege.amount * privilege.price, 0); const priceBeforeDiscounts = tariff.price || tariff.privilegies.reduce((sum, privilege) => sum + privilege.amount * privilege.price, 0);
let priceAfterDiscounts = priceBeforeDiscounts;
let tariffPriceAfterDiscounts = tariff.privilegies.reduce((sum, privilege) => { const cart = calcCart([...currentTariffs, tariff], discounts, purchasesAmount);
let privilegePrice = privilege.amount * privilege.price;
let realprivilegie = privilegies[privilege.serviceKey]?.find(e => e._id === privilege.privilegeId); cart.allAppliedDiscounts.forEach(discount => {
if (realprivilegie) privilege.privilegeId = realprivilegie.privilegeId; priceAfterDiscounts *= findDiscountFactor(discount);
});
const privilegeDiscount = findPrivilegeDiscount(privilege.privilegeId, privilege.price * privilege.amount, discounts); return { priceBeforeDiscounts, priceAfterDiscounts };
privilegePrice *= privilegeDiscount.factor;
const serviceCartData = cart.services.find(e => e.serviceKey === privilege.serviceKey);
let serviceprice = 0;
if (serviceCartData) serviceprice = serviceCartData.price;
const serviceDiscount = findServiceDiscount(privilege.serviceKey, privilegePrice + serviceprice, discounts);
if (serviceDiscount) privilegePrice *= serviceDiscount.factor;
return sum + privilegePrice;
}, 0);
const cartDiscount = findCartDiscount(tariffPriceAfterDiscounts + cart.priceAfterDiscounts, discounts);
tariffPriceAfterDiscounts *= cartDiscount.factor;
const loyalDiscount = findLoyaltyDiscount(purchasesAmount, discounts);
tariffPriceAfterDiscounts *= loyalDiscount.factor;
return {
price,
tariffPriceAfterDiscounts: tariffPriceAfterDiscounts,
};
} }

@ -1,4 +1,4 @@
import { PrivilegeValueType } from "@root/model/privilege"; import { PrivilegeValueType } from "@frontend/kitui";
function declension(number: number, declensions: string[], cases = [2, 0, 1, 1, 1, 2]) { function declension(number: number, declensions: string[], cases = [2, 0, 1, 1, 1, 2]) {
@ -22,4 +22,4 @@ export function getDeclension(number: number, word: PrivilegeValueType | "мес
case "МБ": case "МБ":
return "МБ"; return "МБ";
} }
}; };

@ -0,0 +1,55 @@
import { GetTariffsResponse, Tariff, makeRequest } from "@frontend/kitui";
import { useRef, useLayoutEffect, useEffect } from "react";
export function useAllTariffsFetcher({
baseUrl = process.env.NODE_ENV === "production" ? "/strator/tariff" : "https://hub.pena.digital/strator/tariff",
onSuccess,
onError,
}: {
baseUrl?: string;
onSuccess: (response: Tariff[]) => void;
onError?: (error: Error) => void;
}) {
const onNewTariffsRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onNewTariffsRef.current = onSuccess;
onErrorRef.current = onError;
}, [onError, onSuccess]);
useEffect(() => {
const controller = new AbortController();
async function getPaginatedTariffs() {
let apiPage = 1;
const tariffsPerPage = 100;
let isDone = false;
while (!isDone) {
try {
const result = await makeRequest<never, GetTariffsResponse>({
url: baseUrl + `?page=${apiPage}&limit=${tariffsPerPage}`,
method: "get",
useToken: true,
signal: controller.signal,
});
if (result.tariffs.length > 0) {
onNewTariffsRef.current(result.tariffs);
apiPage++;
} else {
isDone = true;
}
} catch (error) {
onErrorRef.current?.(error as Error);
isDone = true;
}
}
}
getPaginatedTariffs();
return () => controller.abort();
}, [baseUrl]);
}

@ -0,0 +1,50 @@
import { Tariff, makeRequest } from "@frontend/kitui";
import { GetTariffsResponse } from "@root/model/tariff";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
export function useTariffFetcher({
baseUrl = process.env.NODE_ENV === "production" ? "/strator/tariff" : "https://hub.pena.digital/strator/tariff",
tariffsPerPage,
apiPage,
onSuccess,
onError,
}: {
baseUrl?: string;
tariffsPerPage: number;
apiPage: number;
onSuccess: (response: Tariff[]) => void;
onError?: (error: Error) => void;
}) {
const [fetchState, setFetchState] = useState<"fetching" | "idle" | "all fetched">("idle");
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
}, [onError, onSuccess]);
useEffect(() => {
const controller = new AbortController();
setFetchState("fetching");
makeRequest<never, GetTariffsResponse>({
url: baseUrl + `?page=${apiPage}&limit=${tariffsPerPage}`,
method: "get",
useToken: true,
signal: controller.signal,
}).then((result) => {
if (result.tariffs.length > 0) {
onSuccessRef.current(result.tariffs);
setFetchState("idle");
} else setFetchState("all fetched");
}).catch(error => {
onErrorRef.current?.(error);
});
return () => controller.abort();
}, [apiPage, tariffsPerPage, baseUrl]);
return fetchState;
}

@ -1,41 +0,0 @@
import { Tariff, devlog, makeRequest } from "@frontend/kitui";
import { GetTariffsResponse } from "@root/model/tariff";
import { useEffect, useLayoutEffect, useRef } from "react";
export function useTariffs({ baseUrl = "https://admin.pena.digital/strator/tariff", tariffsPerPage, apiPage, onNewTariffs, onError }: {
baseUrl?: string;
tariffsPerPage: number;
apiPage: number;
onNewTariffs: (response: Tariff[]) => void;
onError: (error: Error) => void;
}) {
const onNewTariffsRef = useRef(onNewTariffs);
const onErrorRef = useRef(onError);
useLayoutEffect(() => {
onNewTariffsRef.current = onNewTariffs;
onErrorRef.current = onError;
}, [onError, onNewTariffs]);
useEffect(() => {
const controller = new AbortController();
makeRequest<never, GetTariffsResponse>({
url: baseUrl + `?page=${apiPage}&limit=${tariffsPerPage}`,
method: "get",
useToken: true,
signal: controller.signal,
}).then((result) => {
devlog("Tariffs", result);
if (result.tariffs.length > 0) {
onNewTariffsRef.current(result.tariffs);
}
}).catch(error => {
devlog("Error fetching tariffs", error);
onErrorRef.current(error);
});
return () => controller.abort();
}, [apiPage, tariffsPerPage, baseUrl]);
}

@ -1532,10 +1532,10 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@frontend/kitui@^1.0.17": "@frontend/kitui@1.0.21":
version "1.0.17" version "1.0.21"
resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/21/packages/npm/@frontend/kitui/-/@frontend/kitui-1.0.17.tgz#a5bddaaa18b168be0e1814d5cfbd86e4030d15af" resolved "https://penahub.gitlab.yandexcloud.net/api/v4/projects/21/packages/npm/@frontend/kitui/-/@frontend/kitui-1.0.21.tgz#572b3e774ba42b65c4b946704aeb68237b4b3cd5"
integrity sha1-pb3aqhixaL4OGBTVz72G5AMNFa8= integrity sha1-Vys+d0ukK2XEuUZwSutoI3tLPNU=
dependencies: dependencies:
immer "^10.0.2" immer "^10.0.2"
reconnecting-eventsource "^1.6.2" reconnecting-eventsource "^1.6.2"