Merge branch 'dev' into 'main'

ЛК авторизация. Запрещена автоподстановка в регристрацию и настроку кабинета....

See merge request frontend/marketplace!10
This commit is contained in:
Mikhail 2023-06-30 19:54:05 +00:00
commit ecb8d8cf2e
31 changed files with 1047 additions and 784 deletions

20
src/api/cart.ts Normal file

@ -0,0 +1,20 @@
import { makeRequest } from "@frontend/kitui";
const apiUrl = process.env.NODE_ENV === "production" ? "/customer" : "https://hub.pena.digital/customer";
export function patchCart(tariffId: string) {
return makeRequest<never, string[]>({
url: apiUrl + `/cart?id=${tariffId}`,
method: "PATCH",
useToken: true,
});
}
export function deleteCart(tariffId: string) {
return makeRequest<never, string[]>({
url: apiUrl + `/cart?id=${tariffId}`,
method: "DELETE",
useToken: true,
});
}

@ -1,15 +1,21 @@
import { makeRequest } from "@frontend/kitui"; import { makeRequest } from "@frontend/kitui";
import { CustomTariff } from "@root/model/customTariffs"; import { CreateTariffBody, CustomTariff } from "@root/model/customTariffs";
import { PrivilegeWithoutPrice } from "@root/model/privilege"; import { Tariff } from "@root/model/tariff";
export function createTariff< export function createTariff(tariff: CreateTariffBody) {
T = Omit<CustomTariff, "privilegies"> & { privilegies: PrivilegeWithoutPrice[]; } return makeRequest<CreateTariffBody, CustomTariff>({
>(tariff: T) {
return makeRequest<T, CustomTariff>({
url: `https://admin.pena.digital/strator/tariff`, url: `https://admin.pena.digital/strator/tariff`,
method: "post", method: "post",
useToken: true, useToken: true,
body: tariff, body: tariff,
}); });
}
export function getTariffById(tariffId:string){
return makeRequest<never, Tariff>({
url: `https://admin.pena.digital/strator/tariff/${tariffId}`,
method: "get",
useToken: true,
});
} }

@ -2,156 +2,159 @@ import { useState } from "react";
import { Box, SvgIcon, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, SvgIcon, Typography, useMediaQuery, useTheme } from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import { basketStore } from "@root/stores/BasketStore";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
import { ServiceCartData } from "@root/model/cart";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack";
import { getMessageFromFetchError } from "@frontend/kitui";
const name: Record<string, string> = { templategen: "Шаблонизатор", squiz: "Опросник", reducer: "Скоращатель ссылок" };
interface Props { interface Props {
type: "templ" | "squiz" | "reducer"; serviceData: ServiceCartData;
content: {
name: string;
desc: string;
id: string;
privelegeid: string;
amount: number;
price: number;
}[];
} }
export default function CustomWrapperDrawer({ type, content }: Props) { export default function CustomWrapperDrawer({ serviceData }: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm")); const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const [isExpanded, setIsExpanded] = useState<boolean>(false); const [isExpanded, setIsExpanded] = useState<boolean>(false);
const { remove } = basketStore(); function handleItemDeleteClick(tariffId: string) {
removeTariffFromCart(tariffId).then(() => {
enqueueSnackbar("Тариф удален");
}).catch(error => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
const totalSum = Object.values(content).reduce((accamulator, { price }) => (accamulator += price), 0); return (
const name: Record<string, string> = { templ: "Шаблонизатор", squiz: "Опросник", reducer: "Скоращатель ссылок" };
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 <Box
onClick={() => setIsExpanded((prev) => !prev)} sx={{
sx={{ overflow: "hidden",
height: "72px", borderRadius: "12px",
boxShadow: cardShadow,
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[type]}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
height: "100%",
alignItems: "center",
}}
>
<Typography
sx={{ pr: "11px", color: theme.palette.grey3.main, fontSize: upSm ? "20px" : "16px", fontWeight: 500 }}
>
{totalSum} руб.
</Typography>
<Box <Box
sx={{ sx={{
paddingLeft: upSm ? "24px" : 0, backgroundColor: "white",
height: "100%", "&:first-of-type": {
display: "flex", borderTopLeftRadius: "12px",
justifyContent: "center", borderTopRightRadius: "12px",
alignItems: "center", },
}} "&:last-of-type": {
></Box> borderBottomLeftRadius: "12px",
</Box> borderBottomRightRadius: "12px",
</Box> },
{isExpanded && "&:not(:last-of-type)": {
Object.values(content).map(({ desc, id, privelegeid, amount, price }, index) => ( borderBottom: `1px solid ${theme.palette.grey2.main}`,
<Box },
key={index} }}
sx={{
py: upMd ? "10px" : undefined,
pt: upMd ? undefined : "15px",
pb: upMd ? undefined : "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "15px",
}}
> >
<Typography <Box
sx={{ onClick={() => setIsExpanded((prev) => !prev)}
width: "200px", sx={{
fontSize: upMd ? undefined : "16px", height: "72px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main, display: "flex",
}} alignItems: "center",
> justifyContent: "space-between",
{desc} cursor: "pointer",
</Typography> userSelect: "none",
<Box }}
sx={{
display: "flex",
justifyContent: "space-between",
gap: "10px",
alignItems: "center",
}}
>
<Typography
sx={{
color: theme.palette.grey3.main,
fontSize: "20px",
fontWeight: 500,
}}
> >
{price} руб. <Typography
</Typography> sx={{
fontSize: upMd ? "20px" : "16px",
lineHeight: upMd ? undefined : "19px",
fontWeight: 500,
color: theme.palette.text.secondary,
px: 0,
}}
>
{name[serviceData.serviceKey]}
</Typography>
<SvgIcon <Box
sx={{ cursor: "pointer", color: "#7E2AEA" }} sx={{
onClick={() => remove(type, id)} display: "flex",
component={ClearIcon} justifyContent: "flex-end",
/> height: "100%",
</Box> alignItems: "center",
}}
>
<Typography
sx={{ pr: "11px", color: theme.palette.grey3.main, fontSize: upSm ? "20px" : "16px", fontWeight: 500 }}
>
{currencyFormatter.format(serviceData.price / 100)}
</Typography>
<Box
sx={{
paddingLeft: upSm ? "24px" : 0,
height: "100%",
display: "flex",
justifyContent: "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
sx={{
width: "200px",
fontSize: upMd ? undefined : "16px",
lineHeight: upMd ? undefined : "19px",
color: theme.palette.grey3.main,
}}
>
{privilege.name}
</Typography>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
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", color: "#7E2AEA" }}
onClick={() => handleItemDeleteClick(privilege.tariffId)}
component={ClearIcon}
/>
</Box>
</Box>
))}
</Box> </Box>
))} </Box>
</Box> );
</Box>
);
} }

@ -1,228 +1,185 @@
import React, { useEffect } from "react";
import { Typography, Drawer, useMediaQuery, useTheme, Box, IconButton, SvgIcon, Icon } from "@mui/material"; import { Typography, Drawer, useMediaQuery, useTheme, Box, IconButton, SvgIcon, Icon } from "@mui/material";
import { IconsCreate } from "@root/lib/IconsCreate"; import { IconsCreate } from "@root/lib/IconsCreate";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import { basketStore } from "@root/stores/BasketStore";
import { useState } from "react";
import BasketIcon from "../assets/Icons/BasketIcon.svg"; import BasketIcon from "../assets/Icons/BasketIcon.svg";
import SectionWrapper from "./SectionWrapper"; import SectionWrapper from "./SectionWrapper";
import CustomWrapperDrawer from "./CustomWrapperDrawer"; import CustomWrapperDrawer from "./CustomWrapperDrawer";
import CustomButton from "./CustomButton"; import CustomButton from "./CustomButton";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useCart } from "@root/utils/hooks/useCart";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { closeCartDrawer, openCartDrawer, useCartStore } from "@root/stores/cart";
interface TabPanelProps {
index: number;
value: number;
children?: React.ReactNode;
mt: string;
}
type BasketItem = {
name: string;
desc: string;
id: string;
privelegeid: string;
amount: number;
price: number;
}[];
function TabPanel({ index, value, children, mt }: TabPanelProps) {
return (
<Box hidden={index !== value} sx={{ mt }}>
{children}
</Box>
);
}
export default function Drawers() { export default function Drawers() {
const [tabIndex, setTabIndex] = useState<number>(0); const navigate = useNavigate();
const [basketQuantity, setBasketQuantity] = useState<number>(); const theme = useTheme();
const navigate = useNavigate(); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const { templ, squiz, reducer, open, openDrawer } = basketStore(); const isDrawerOpen = useCartStore(state => state.isDrawerOpen);
const theme = useTheme(); const cart = useCart();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const newArray: BasketItem = [...Object.values(templ), ...Object.values(squiz), ...Object.values(reducer)]; return (
const sum = newArray.reduce((accamulator, { price }) => (accamulator += price), 0); <IconButton sx={{ p: 0 }}>
<Typography onClick={openCartDrawer} component="div" sx={{ position: "absolute" }}>
useEffect(() => { <IconsCreate svg={BasketIcon} bgcolor="#F2F3F7" />
setBasketQuantity(Object.keys(templ).length + Object.keys(squiz).length + Object.keys(reducer).length);
}, [templ, squiz, reducer]);
return (
<IconButton sx={{ p: 0 }}>
<Typography onClick={open(true)} component="div" sx={{ position: "absolute" }}>
<IconsCreate svg={BasketIcon} bgcolor="#F2F3F7" />
</Typography>
{basketQuantity && (
<Icon
component="div"
sx={{
position: "relative",
left: "8px",
bottom: "7px",
width: "16px",
height: "16px",
backgroundColor: "#7E2AEA",
borderRadius: "12px",
}}
>
<Typography
component="div"
sx={{
display: "flex",
fontSize: "12px",
mt: "4.5px",
width: "100%",
height: "9px",
color: "white",
alignItems: "center",
justifyContent: "center",
}}
>
{basketQuantity}
</Typography>
</Icon>
)}
<Drawer anchor={"right"} open={openDrawer} onClose={open(false)}>
<SectionWrapper
maxWidth="lg"
sx={{
pl: "0px",
pr: "0px",
width: "450px",
}}
>
<Box
sx={{
width: "100%",
pt: "20px",
pb: "20px",
display: "flex",
justifyContent: "space-between",
bgcolor: "#F2F3F7",
gap: "10px",
pl: "20px",
pr: "20px",
}}
>
{!upMd && (
<IconButton sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
>
Корзина
</Typography> </Typography>
<SvgIcon onClick={open(false)} sx={{ cursor: "pointer" }} component={ClearIcon} /> {cart.itemCount && (
</Box> <Icon
<Box sx={{ pl: "20px", pr: "20px" }}> component="div"
<TabPanel value={tabIndex} index={0} mt={"10px"}>
{Object.keys(templ).length > 0 ? (
<CustomWrapperDrawer type="templ" content={Object.values(templ)} />
) : (
<></>
)}
{Object.keys(squiz).length > 0 ? (
<CustomWrapperDrawer type="squiz" content={Object.values(squiz)} />
) : (
<></>
)}
{Object.keys(reducer).length > 0 ? (
<CustomWrapperDrawer type="reducer" content={Object.values(reducer)} />
) : (
<></>
)}
</TabPanel>
<Box
sx={{
mt: "40px",
pt: upMd ? "30px" : undefined,
borderTop: upMd ? `1px solid ${theme.palette.grey2.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.grey3.main}>
Текст-заполнитель это текст, который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель это текст, который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель
</Typography>
</Box>
<Box
sx={{
color: theme.palette.grey3.main,
pb: "100px",
pt: "38px",
pl: upMd ? "20px" : undefined,
}}
>
<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={{ sx={{
textDecoration: "line-through", position: "relative",
order: upMd ? 1 : 2, left: "8px",
bottom: "7px",
width: "16px",
height: "16px",
backgroundColor: "#7E2AEA",
borderRadius: "12px",
}} }}
>
20 190 руб.
</Typography>
<Typography
variant="p1"
sx={{
fontWeight: 500,
fontSize: "26px",
lineHeight: "31px",
order: upMd ? 2 : 1,
}}
>
{sum} руб.
</Typography>
</Box>
<CustomButton
variant="contained"
onClick={() => navigate("/basket")}
sx={{
mt: "25px",
backgroundColor: theme.palette.brightPurple.main,
}}
> >
Оплатить <Typography
</CustomButton> component="div"
</Box> sx={{
</Box> display: "flex",
</Box> fontSize: "12px",
</SectionWrapper> mt: "4.5px",
</Drawer> width: "100%",
</IconButton> height: "9px",
); color: "white",
alignItems: "center",
justifyContent: "center",
}}
>
{cart.itemCount}
</Typography>
</Icon>
)}
<Drawer anchor={"right"} open={isDrawerOpen} onClose={closeCartDrawer}>
<SectionWrapper
maxWidth="lg"
sx={{
pl: "0px",
pr: "0px",
width: "450px",
}}
>
<Box
sx={{
width: "100%",
pt: "20px",
pb: "20px",
display: "flex",
justifyContent: "space-between",
bgcolor: "#F2F3F7",
gap: "10px",
pl: "20px",
pr: "20px",
}}
>
{!upMd && (
<IconButton sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
>
Корзина
</Typography>
<IconButton onClick={closeCartDrawer} sx={{ p: 0 }}>
<SvgIcon component={ClearIcon} />
</IconButton>
</Box>
<Box sx={{ pl: "20px", pr: "20px" }}>
{cart.services.map(serviceData =>
<CustomWrapperDrawer
key={serviceData.serviceKey}
serviceData={serviceData}
/>
)}
<Box
sx={{
mt: "40px",
pt: upMd ? "30px" : undefined,
borderTop: upMd ? `1px solid ${theme.palette.grey2.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.grey3.main}>
Текст-заполнитель это текст, который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель это текст, который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель
</Typography>
</Box>
<Box
sx={{
color: theme.palette.grey3.main,
pb: "100px",
pt: "38px",
pl: upMd ? "20px" : undefined,
}}
>
<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,
}}
>
{currencyFormatter.format(cart.priceAfterDiscounts / 100)}
</Typography>
</Box>
<CustomButton
variant="contained"
onClick={() => navigate("/basket")}
sx={{
mt: "25px",
backgroundColor: theme.palette.brightPurple.main,
}}
>
Оплатить
</CustomButton>
</Box>
</Box>
</Box>
</SectionWrapper>
</Drawer>
</IconButton>
);
} }

@ -1,10 +1,6 @@
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import { Box, Button, Container, IconButton, Typography, useTheme } from "@mui/material"; import { Box, Button, Container, IconButton, Typography, useTheme } from "@mui/material";
import SectionWrapper from "../SectionWrapper"; import SectionWrapper from "../SectionWrapper";
import { basketStore } from "@stores/BasketStore";
import LogoutIcon from "../icons/LogoutIcon"; import LogoutIcon from "../icons/LogoutIcon";
import WalletIcon from "../icons/WalletIcon"; import WalletIcon from "../icons/WalletIcon";
import CustomAvatar from "./Avatar"; import CustomAvatar from "./Avatar";
@ -26,14 +22,6 @@ export default function NavbarFull({ isLoggedIn }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const { open } = basketStore();
useEffect(() => {
if (location.pathname === "/basket") {
open(false);
}
}, [location.pathname, open]);
async function handleLogoutClick() { async function handleLogoutClick() {
try { try {
await logout(); await logout();

@ -1,25 +1,16 @@
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { basketStore } from "@root/stores/BasketStore";
import CustomButton from "./CustomButton"; import CustomButton from "./CustomButton";
import { currencyFormatter } from "@root/utils/currencyFormatter";
export default function TotalPrice() { interface Props {
price: number;
priceWithDiscounts: number;
}
export default function TotalPrice({price,priceWithDiscounts}:Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const { templ, squiz, reducer } = basketStore();
type BasketItem = {
name: string;
desc: string;
id: string;
privelegeid: string;
amount: number;
price: number;
}[];
const newArray: BasketItem = [...Object.values(templ), ...Object.values(squiz), ...Object.values(reducer)];
const sum = newArray.reduce((accamulator, { price }) => (accamulator += price), 0);
return ( return (
<Box <Box
@ -71,7 +62,7 @@ export default function TotalPrice() {
order: upMd ? 1 : 2, order: upMd ? 1 : 2,
}} }}
> >
20 190 руб. {currencyFormatter.format(price / 100)}
</Typography> </Typography>
<Typography <Typography
variant="p1" variant="p1"
@ -82,7 +73,7 @@ export default function TotalPrice() {
order: upMd ? 2 : 1, order: upMd ? 2 : 1,
}} }}
> >
{sum} руб. {currencyFormatter.format(priceWithDiscounts / 100)}
</Typography> </Typography>
</Box> </Box>
<CustomButton <CustomButton

@ -0,0 +1,120 @@
import {
FormControl,
IconButton,
InputLabel,
SxProps,
TextField,
TextFieldProps,
Theme,
useMediaQuery,
useTheme,
} from "@mui/material";
import * as React from 'react';
import InputAdornment from '@mui/material/InputAdornment';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
interface Props {
id: string;
label?: string;
bold?: boolean;
gap?: string;
color?: string;
FormInputSx?: SxProps<Theme>;
TextfieldProps: TextFieldProps;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
}
export default function ({
id,
label,
bold = false,
gap = "10px",
onChange,
TextfieldProps,
color,
FormInputSx,
}: Props) {
const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md"));
const labelFont = upMd
? bold
? theme.typography.p1
: { ...theme.typography.body1, fontWeight: 500 }
: theme.typography.body2;
const placeholderFont = upMd ? undefined : { fontWeight: 400, fontSize: "16px", lineHeight: "19px" };
const [showPassword, setShowPassword] = React.useState(false);
const handleClickShowPassword = () => setShowPassword((show) => !show);
const handleMouseDownPassword = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
};
return (
<FormControl
fullWidth
variant="standard"
sx={{
gap,
// mt: "10px",
...FormInputSx,
position: "relative"
}}
>
<InputLabel
shrink
htmlFor={id}
sx={{
position: "inherit",
color: "black",
transform: "none",
...labelFont,
}}
>
{label}
</InputLabel>
<TextField
{...TextfieldProps}
fullWidth
id={id}
sx={{
"& .MuiInputBase-root": {
height: "48px",
borderRadius: "8px",
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
sx: {
backgroundColor: color,
borderRadius: "8px",
height: "48px",
py: 0,
color: "black",
...placeholderFont,
},
}}
onChange={onChange}
type={showPassword ? 'text' : 'password'}
/>
</FormControl>
);
}

19
src/model/cart.ts Normal file

@ -0,0 +1,19 @@
export type PrivilegeCartData = {
tariffId: string;
privilegeId: string;
name: string;
price: number;
};
export type ServiceCartData = {
serviceKey: string;
privileges: PrivilegeCartData[];
price: number;
};
export type CartData = {
services: ServiceCartData[];
priceBeforeDiscounts: number;
priceAfterDiscounts: number;
itemCount: number;
};

@ -1,4 +1,4 @@
import { PrivilegeWithAmount } from "./privilege"; import { PrivilegeWithAmount, PrivilegeWithoutPrice } from "./privilege";
export type CustomTariffUserValues = Record<string, number>; export type CustomTariffUserValues = Record<string, number>;
@ -15,4 +15,6 @@ export interface CustomTariff {
updatedAt?: string; updatedAt?: string;
isDeleted?: boolean; isDeleted?: boolean;
createdAt?: string; createdAt?: string;
} }
export type CreateTariffBody = Omit<CustomTariff, "privilegies"> & { privilegies: PrivilegeWithoutPrice[]; };

@ -9,6 +9,7 @@ export interface GetTariffsResponse {
export interface Tariff { export interface Tariff {
_id: string; _id: string;
name: string; name: string;
/** Кастомная цена, undefined если isCustom === true */
price?: number; price?: number;
isCustom: boolean; isCustom: boolean;
privilegies: PrivilegeWithAmount[]; privilegies: PrivilegeWithAmount[];

@ -1,6 +1,7 @@
import { Box, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, SxProps, Theme, Typography, useMediaQuery, useTheme } from "@mui/material";
import CustomButton from "@components/CustomButton"; import CustomButton from "@components/CustomButton";
import InputTextfield from "@components/InputTextfield"; import InputTextfield from "@components/InputTextfield";
import PasswordInput from "@components/passwordInput";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import ComplexNavText from "@root/components/ComplexNavText"; import ComplexNavText from "@root/components/ComplexNavText";
import { openDocumentsDialog, sendUserData, setSettingsField, useUserStore } from "@root/stores/user"; import { openDocumentsDialog, sendUserData, setSettingsField, useUserStore } from "@root/stores/user";
@ -29,7 +30,7 @@ export default function AccountSettings() {
}; };
function handleSendDataClick() { function handleSendDataClick() {
sendUserData().then(result => { sendUserData().then(() => {
enqueueSnackbar("Информация обновлена"); enqueueSnackbar("Информация обновлена");
}).catch(error => { }).catch(error => {
const message = getMessageFromFetchError(error); const message = getMessageFromFetchError(error);
@ -46,7 +47,6 @@ export default function AccountSettings() {
}} }}
> >
<DocumentsDialog /> <DocumentsDialog />
<ComplexNavText text1="Настройки аккаунта" />
<Typography variant="h4" mt="20px">Настройки аккаунта</Typography> <Typography variant="h4" mt="20px">Настройки аккаунта</Typography>
<Box sx={{ <Box sx={{
mt: "40px", mt: "40px",
@ -145,13 +145,13 @@ export default function AccountSettings() {
label="Телефон" label="Телефон"
{...textFieldProps} {...textFieldProps}
/> />
<InputTextfield <PasswordInput
TextfieldProps={{ TextfieldProps={{
placeholder: "Не менее 8 символов", placeholder: "Не менее 8 символов",
value: fields.password.value || "", value: fields.password.value || "",
helperText: fields.password.touched && fields.password.error, helperText: fields.password.touched && fields.password.error,
error: fields.password.touched && Boolean(fields.password.error), error: fields.password.touched && Boolean(fields.password.error),
type: "password", autoComplete: "new-password"
}} }}
onChange={e => setSettingsField("password", e.target.value)} onChange={e => setSettingsField("password", e.target.value)}
id="password" id="password"

@ -1,72 +1,54 @@
import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useState } from "react";
import TotalPrice from "@components/TotalPrice"; import TotalPrice from "@components/TotalPrice";
import { basketStore } from "@root/stores/BasketStore";
import CustomWrapper from "./CustomWrapper"; import CustomWrapper from "./CustomWrapper";
import ComplexNavText from "@root/components/ComplexNavText"; import ComplexNavText from "@root/components/ComplexNavText";
import { useCart } from "@root/utils/hooks/useCart";
interface TabPanelProps {
index: number;
value: number;
children?: React.ReactNode;
mt: string;
}
function TabPanel({ index, value, children, mt }: TabPanelProps) {
return (
<Box hidden={index !== value} sx={{ mt }}>
{children}
</Box>
);
}
export default function Basket() { export default function Basket() {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const cart = useCart();
const [tabIndex, setTabIndex] = useState<number>(0); return (
const { templ, squiz, reducer, open } = basketStore(); <SectionWrapper
maxWidth="lg"
const handleChange = (event: React.SyntheticEvent, newValue: number) => { sx={{
setTabIndex(newValue); mt: upMd ? "25px" : "20px",
}; mb: upMd ? "70px" : "37px",
}}
open(false); >
{upMd && <ComplexNavText text1="Все тарифы — " text2="Корзина" />}
return ( <Box
<SectionWrapper sx={{
maxWidth="lg" mt: "20px",
sx={{ mb: upMd ? "40px" : "20px",
mt: upMd ? "25px" : "20px", display: "flex",
mb: upMd ? "70px" : "37px", gap: "10px",
}} }}
> >
{upMd && <ComplexNavText text1="Все тарифы — " text2="Корзина" />} {!upMd && (
<Box <IconButton sx={{ p: 0, height: "28px", width: "28px", color: "black" }}>
sx={{ <ArrowBackIcon />
mt: "20px", </IconButton>
mb: upMd ? "40px" : "20px", )}
display: "flex", <Typography component="h4" variant="h4">
gap: "10px", Корзина
}} </Typography>
> </Box>
{!upMd && ( <Box sx={{
<IconButton sx={{ p: 0, height: "28px", width: "28px", color: "black" }}> mt: upMd ? "27px" : "10px",
<ArrowBackIcon /> }}>
</IconButton> {cart.services.map(serviceData =>
)} <CustomWrapper
<Typography component="h4" variant="h4"> key={serviceData.serviceKey}
Корзина serviceData={serviceData}
</Typography> />
</Box> )}
<TabPanel value={tabIndex} index={0} mt={upMd ? "27px" : "10px"}> </Box>
{Object.keys(templ).length > 0 ? <CustomWrapper type="templ" content={templ} /> : <></>} <TotalPrice price={cart.priceBeforeDiscounts} priceWithDiscounts={cart.priceAfterDiscounts} />
{Object.keys(squiz).length > 0 ? <CustomWrapper type="squiz" content={squiz} /> : <></>} </SectionWrapper>
{Object.keys(reducer).length > 0 ? <CustomWrapper type="reducer" content={reducer} /> : <></>} );
</TabPanel>
<TotalPrice />
</SectionWrapper>
);
} }

@ -1,177 +1,176 @@
import { useState } from "react"; import { useState } from "react";
import { Box, SvgIcon, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, SvgIcon, Typography, useMediaQuery, useTheme } from "@mui/material";
import ExpandIcon from "@components/icons/ExpandIcon"; import ExpandIcon from "@components/icons/ExpandIcon";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import { basketStore } from "@root/stores/BasketStore";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
import { ServiceCartData } from "@root/model/cart";
import { currencyFormatter } from "@root/utils/currencyFormatter";
import { removeTariffFromCart } from "@root/stores/user";
import { enqueueSnackbar } from "notistack";
import { getMessageFromFetchError } from "@frontend/kitui";
interface Templ {
name: string; const name: Record<string, string> = { templategen: "Шаблонизатор", squiz: "Опросник", reducer: "Сокращатель ссылок" };
desc: string;
id: string;
privelegeid?: string;
amount: number;
price: number;
}
interface Props { interface Props {
type: "templ" | "squiz" | "reducer"; serviceData: ServiceCartData;
content: Record<string, Templ>;
} }
export default function CustomWrapper({ type, content }: Props) { export default function CustomWrapper({ serviceData }: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const upSm = useMediaQuery(theme.breakpoints.up("sm")); const upSm = useMediaQuery(theme.breakpoints.up("sm"));
const [isExpanded, setIsExpanded] = useState<boolean>(false); const [isExpanded, setIsExpanded] = useState<boolean>(false);
const { remove } = basketStore(); function handleItemDeleteClick(tariffId: string) {
removeTariffFromCart(tariffId).then(() => {
enqueueSnackbar("Тариф удален");
}).catch(error => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
const totalSum = Object.values(content).reduce((accamulator, { price }) => (accamulator += price), 0); return (
const name: Record<string, string> = { templ: "Шаблонизатор", squiz: "Опросник", reducer: "Скоращатель ссылок" };
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 <Box
onClick={() => setIsExpanded((prev) => !prev)} sx={{
sx={{ overflow: "hidden",
height: "72px", borderRadius: "12px",
px: "20px", boxShadow: cardShadow,
}}
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[type]}
</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 }}>
{totalSum} руб.
</Typography>
<Box <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 &&
Object.values(content).map(({ desc, id, privelegeid, amount, price }, index) => (
<Box
key={index}
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={{ sx={{
fontSize: upMd ? undefined : "16px", backgroundColor: "white",
lineHeight: upMd ? undefined : "19px", "&:first-of-type": {
color: theme.palette.grey3.main, borderTopLeftRadius: "12px",
borderTopRightRadius: "12px",
},
"&:last-of-type": {
borderBottomLeftRadius: "12px",
borderBottomRightRadius: "12px",
},
"&:not(:last-of-type)": {
borderBottom: `1px solid ${theme.palette.grey2.main}`,
},
}} }}
> >
{desc} <Box
</Typography> onClick={() => setIsExpanded((prev) => !prev)}
<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,
}}
>
{price} руб.
</Typography>
{upSm ? (
<Typography
component="div"
onClick={() => remove(type, id)}
sx={{ sx={{
color: theme.palette.text.secondary, height: "72px",
borderBottom: `1px solid ${theme.palette.text.secondary}`, px: "20px",
width: "max-content",
lineHeight: "19px", display: "flex",
cursor: "pointer", alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
userSelect: "none",
}} }}
> >
Удалить <Typography
</Typography> sx={{
) : ( fontSize: upMd ? "20px" : "16px",
<SvgIcon onClick={() => remove(type, id)} component={ClearIcon}></SvgIcon> lineHeight: upMd ? undefined : "19px",
)} fontWeight: 500,
</Box> 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.name}
</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}></SvgIcon>
)}
</Box>
</Box>
))}
</Box> </Box>
))} </Box>
</Box> );
</Box>
);
} }

@ -1,78 +1,31 @@
import { Box, SxProps, Theme } from "@mui/material";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useNavigate } from "react-router-dom"; import TariffCard from "./TariffCard";
import NumberIcon from "@root/components/NumberIcon";
import CustomButton from "@components/CustomButton";
import { IconsCreate } from "../../lib/IconsCreate"; export default function FreeTariffCard() {
import ZeroIcons from "../../assets/Icons/ZeroIcons.svg"; return (
<TariffCard
interface Props { icon={<NumberIcon
headerText: string; number={0}
text: string; color="#7E2AEA"
money?: string; backgroundColor="white"
sx: SxProps<Theme>; />}
href: string; buttonText="Выбрать"
} headerText="бесплатно"
text="Текст-заполнитель — это текст, который имеет "
export default function FreeTariffCard({ headerText, text, sx, href, money = "0" }: Props) { onButtonClick={undefined}
const navigate = useNavigate(); price={<Typography variant="price" color="white">0 руб.</Typography>}
const icon = <IconsCreate svg={ZeroIcons} bgcolor="#FFFFFF" />; sx={{
backgroundColor: "#7E2AEA",
return ( color: "white",
<Box maxWidth: "360px",
component="div" }}
sx={{ buttonSx={{
maxWidth: "360px", color: "white",
width: "360px", borderColor: "white",
bgcolor: "white", }}
borderRadius: "12px", />
display: "flex", );
flexDirection: "column",
alignItems: "start",
p: "20px",
...sx,
}}
>
<Box
component="div"
sx={{
display: "flex",
justifyContent: "space-around",
alignItems: "center",
width: "100%",
}}
>
<Typography component="div">{icon}</Typography>
<Typography
variant="h5"
sx={{
display: "flex",
justifyContent: "right",
width: "100%",
color: "white",
}}
>
{money} руб.
</Typography>
</Box>
<Typography variant="h5" sx={{ mt: "14px", mb: "10px" }}>
{headerText}
</Typography>
<Typography sx={{ minHeight: "calc(1.185*2em)" }}>{text}</Typography>
<CustomButton
onClick={() => navigate(href)}
variant="outlined"
sx={{
color: "white",
borderColor: "white",
mt: "33px",
}}
>
Подробнее
</CustomButton>
</Box>
);
} }

@ -21,7 +21,6 @@ export default function TariffCard({ icon, headerText, text, sx, buttonText, onB
return ( return (
<Box sx={{ <Box sx={{
maxWidth: "360px",
width: "100%", width: "100%",
bgcolor: "white", bgcolor: "white",
borderRadius: "12px", borderRadius: "12px",

@ -46,6 +46,7 @@ export default function Tariffs() {
text="безлимит на 1 месяц , 3 , 6 , 12" text="безлимит на 1 месяц , 3 , 6 , 12"
buttonText="Подробнее" buttonText="Подробнее"
onButtonClick={() => navigate("time")} onButtonClick={() => navigate("time")}
sx={{ maxWidth: "360px" }}
/> />
<TariffCard <TariffCard
icon={<PieChartIcon color="white" bgcolor={theme.palette.brightPurple.main} />} icon={<PieChartIcon color="white" bgcolor={theme.palette.brightPurple.main} />}
@ -53,6 +54,7 @@ export default function Tariffs() {
text="200 шаблонов, 1000 шаблонов, 5000 шаблонов, 10 000 шаблонов" text="200 шаблонов, 1000 шаблонов, 5000 шаблонов, 10 000 шаблонов"
buttonText="Подробнее" buttonText="Подробнее"
onButtonClick={() => navigate("volume")} onButtonClick={() => navigate("volume")}
sx={{ maxWidth: "360px" }}
/> />
<TariffCard <TariffCard
icon={<CustomIcon color="white" bgcolor={theme.palette.brightPurple.main} />} icon={<CustomIcon color="white" bgcolor={theme.palette.brightPurple.main} />}
@ -60,6 +62,7 @@ export default function Tariffs() {
text="Текст-заполнитель — это текст, который имеет " text="Текст-заполнитель — это текст, который имеет "
buttonText="Подробнее" buttonText="Подробнее"
onButtonClick={() => navigate("/tariffconstructor")} onButtonClick={() => navigate("/tariffconstructor")}
sx={{ maxWidth: "360px" }}
/> />
</Box> </Box>
<Typography component="div"> <Typography component="div">

@ -1,10 +1,10 @@
import { useCallback, useState } from "react"; import { useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Box, Tabs, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Tabs, Typography, useMediaQuery, useTheme } from "@mui/material";
import SectionWrapper from "@components/SectionWrapper"; import SectionWrapper from "@components/SectionWrapper";
import ComplexNavText from "@root/components/ComplexNavText"; import ComplexNavText from "@root/components/ComplexNavText";
import { useTariffs } from "@root/utils/hooks/useTariffs"; import { useTariffs } from "@root/utils/hooks/useTariffs";
import { setTariffs, useTariffStore } from "@root/stores/tariffs"; import { updateTariffs, useTariffStore } from "@root/stores/tariffs";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { CustomTab } from "@root/components/CustomTab"; import { CustomTab } from "@root/components/CustomTab";
import TariffCard from "./TariffCard"; import TariffCard from "./TariffCard";
@ -12,6 +12,8 @@ import NumberIcon from "@root/components/NumberIcon";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { calcTariffPrices } from "@root/utils/calcTariffPrices"; import { calcTariffPrices } from "@root/utils/calcTariffPrices";
import { getMessageFromFetchError } from "@frontend/kitui"; import { getMessageFromFetchError } from "@frontend/kitui";
import FreeTariffCard from "./FreeTariffCard";
import { addTariffToCart } from "@root/stores/user";
export default function TariffPage() { export default function TariffPage() {
@ -26,20 +28,58 @@ export default function TariffPage() {
const StepperText: Record<string, string> = { volume: "Тарифы на объём", time: "Тарифы на время" }; const StepperText: Record<string, string> = { volume: "Тарифы на объём", time: "Тарифы на время" };
useTariffs({ useTariffs({
url: "https://admin.pena.digital/strator/tariff",
apiPage: 0, apiPage: 0,
tariffsPerPage: 100, tariffsPerPage: 100,
onNewTariffs: setTariffs, onNewTariffs: updateTariffs,
onError: useCallback(error => { onError: error => {
const errorMessage = getMessageFromFetchError(error); const errorMessage = getMessageFromFetchError(error);
if (errorMessage) enqueueSnackbar(errorMessage); if (errorMessage) enqueueSnackbar(errorMessage);
}, []) }
}); });
function handleTariffItemClick(tariffId: string) {
addTariffToCart(tariffId).then(() => {
enqueueSnackbar("Тариф добавлен в корзину");
}).catch(error => {
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message);
});
}
const filteredTariffs = tariffs.filter(tariff => { const filteredTariffs = tariffs.filter(tariff => {
return tariff.privilegies.map(p => p.type).includes("day") === (unit === "time"); return tariff.privilegies.map(p => p.type).includes("day") === (unit === "time");
}); });
const tariffElements = filteredTariffs.map((tariff, index) => {
const { price, priceWithDiscounts } = calcTariffPrices(tariff);
return (
<TariffCard
key={tariff._id}
icon={<NumberIcon
number={(index + 1) % 10}
color={unit === "time" ? "#7E2AEA" : "#FB5607"}
backgroundColor={unit === "time" ? "#EEE4FC" : "#FEDFD0"}
/>}
buttonText="Выбрать"
headerText={tariff.name}
text={tariff.privilegies.map(p => `${p.name} - ${p.amount}`)}
onButtonClick={() => handleTariffItemClick(tariff._id)}
price={<>
{price !== undefined && price !== priceWithDiscounts &&
<Typography variant="oldPrice">{currencyFormatter.format(price / 100)}</Typography>
}
{priceWithDiscounts !== undefined &&
<Typography variant="price">{currencyFormatter.format(priceWithDiscounts / 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 (
<SectionWrapper <SectionWrapper
maxWidth="lg" maxWidth="lg"
@ -72,52 +112,7 @@ export default function TariffPage() {
gap: "40px", gap: "40px",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
}}> }}>
{filteredTariffs.map((tariff, index) => { {tariffElements}
const { price, priceWithDiscounts } = calcTariffPrices(tariff);
return (
<TariffCard
key={tariff._id}
icon={<NumberIcon
number={(index + 1) % 10}
color={unit === "time" ? "#7E2AEA" : "#FB5607"}
backgroundColor={unit === "time" ? "#EEE4FC" : "#FEDFD0"}
/>}
buttonText="Выбрать"
headerText={tariff.name}
text={tariff.privilegies.map(p => `${p.name} - ${p.amount}`)}
onButtonClick={undefined}
price={<>
{price !== undefined && price !== priceWithDiscounts &&
<Typography variant="oldPrice">{currencyFormatter.format(price / 100)}</Typography>
}
{priceWithDiscounts !== undefined &&
<Typography variant="price">{currencyFormatter.format(priceWithDiscounts / 100)}</Typography>
}
</>}
/>
);
})}
<TariffCard
icon={<NumberIcon
number={0}
color="#7E2AEA"
backgroundColor="white"
/>}
buttonText="Выбрать"
headerText="бесплатно"
text="Текст-заполнитель — это текст, который имеет "
onButtonClick={undefined}
price={<Typography variant="price" color="white">0 руб.</Typography>}
sx={{
backgroundColor: "#7E2AEA",
color: "white",
}}
buttonSx={{
color: "white",
borderColor: "white",
}}
/>
</Box> </Box>
</SectionWrapper> </SectionWrapper>
); );

@ -14,6 +14,7 @@ import { setUserId, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@frontend/kitui"; import { getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui"; import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
import PasswordInput from "@root/components/passwordInput";
interface Values { interface Values {
login: string; login: string;
@ -147,7 +148,7 @@ export default function SigninDialog() {
label="Логин" label="Логин"
gap={upMd ? "10px" : "10px"} gap={upMd ? "10px" : "10px"}
/> />
<InputTextfield <PasswordInput
TextfieldProps={{ TextfieldProps={{
value: formik.values.password, value: formik.values.password,
placeholder: "Не менее 8 символов", placeholder: "Не менее 8 символов",

@ -14,6 +14,7 @@ import { setUserId, useUserStore } from "@root/stores/user";
import { getMessageFromFetchError } from "@frontend/kitui"; import { getMessageFromFetchError } from "@frontend/kitui";
import { makeRequest } from "@frontend/kitui"; import { makeRequest } from "@frontend/kitui";
import { cardShadow } from "@root/utils/themes/shadow"; import { cardShadow } from "@root/utils/themes/shadow";
import PasswordInput from "@root/components/passwordInput";
interface Values { interface Values {
@ -50,7 +51,7 @@ export default function SignupDialog() {
body: { body: {
login: values.login.trim(), login: values.login.trim(),
password: values.password.trim(), password: values.password.trim(),
phoneNumber: "-", phoneNumber: "+7",
}, },
useToken: false, useToken: false,
withCredentials: true, withCredentials: true,
@ -146,14 +147,14 @@ export default function SignupDialog() {
label="Логин" label="Логин"
gap={upMd ? "10px" : "10px"} gap={upMd ? "10px" : "10px"}
/> />
<InputTextfield <PasswordInput
TextfieldProps={{ TextfieldProps={{
value: formik.values.password, value: formik.values.password,
placeholder: "Не менее 8 символов", placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur, onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password), error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password, helperText: formik.touched.password && formik.errors.password,
type: "password", autoComplete: "new-password"
}} }}
onChange={formik.handleChange} onChange={formik.handleChange}
color="#F2F3F7" color="#F2F3F7"
@ -161,14 +162,14 @@ export default function SignupDialog() {
label="Пароль" label="Пароль"
gap={upMd ? "10px" : "10px"} gap={upMd ? "10px" : "10px"}
/> />
<InputTextfield <PasswordInput
TextfieldProps={{ TextfieldProps={{
value: formik.values.repeatPassword, value: formik.values.repeatPassword,
placeholder: "Не менее 8 символов", placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur, onBlur: formik.handleBlur,
error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword), error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword),
helperText: formik.touched.repeatPassword && formik.errors.repeatPassword, helperText: formik.touched.repeatPassword && formik.errors.repeatPassword,
type: "password", autoComplete: "new-password"
}} }}
onChange={formik.handleChange} onChange={formik.handleChange}
color="#F2F3F7" color="#F2F3F7"

80
src/stores/cart.ts Normal file

@ -0,0 +1,80 @@
import { CartData } from "@root/model/cart";
import { Tariff } from "@root/model/tariff";
import { calcCart } from "@root/utils/calcCart";
import { produce } from "immer";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface CartStore {
cartTariffMap: Record<string, Tariff | "loading" | "not found">;
cart: CartData;
isDrawerOpen: boolean;
}
export const useCartStore = create<CartStore>()(
devtools(
(get, set) => ({
cartTariffMap: {},
cart: {
services: [],
priceBeforeDiscounts: 0,
priceAfterDiscounts: 0,
itemCount: 0,
},
isDrawerOpen: false,
}),
{
name: "Cart",
enabled: process.env.NODE_ENV === "development",
trace: true,
actionsBlacklist: "rejected",
}
)
);
export const setCartTariffStatus = (tariffId: string, status: "loading" | "not found") => useCartStore.setState(
produce<CartStore>(state => {
state.cartTariffMap[tariffId] = status;
}),
false,
{
type: "setCartTariffStatus",
tariffId,
status,
}
);
export const addCartTariffs = (tariffs: Tariff[]) => useCartStore.setState(
produce<CartStore>(state => {
tariffs.forEach(tariff => {
state.cartTariffMap[tariff._id] = tariff;
});
const cartTariffs = Object.values(state.cartTariffMap).filter((tariff): tariff is Tariff => typeof tariff === "object");
state.cart = calcCart(cartTariffs);
}),
false,
{
type: tariffs.length > 0 ? "addCartTariffs" : "rejected",
tariffIds: tariffs.map(tariff => tariff._id),
}
);
export const removeMissingTariffs = (tariffIds: string[]) => useCartStore.setState(
produce<CartStore>(state => {
for (const key in state.cartTariffMap) {
if (!tariffIds.includes(key)) delete state.cartTariffMap[key];
}
const cartTariffs = Object.values(state.cartTariffMap).filter((tariff): tariff is Tariff => typeof tariff === "object");
state.cart = calcCart(cartTariffs);
}),
false,
{
type: "removeMissingTariffs",
tariffIds,
}
);
export const openCartDrawer = () => useCartStore.setState({ isDrawerOpen: true });
export const closeCartDrawer = () => useCartStore.setState({ isDrawerOpen: false });

@ -21,7 +21,7 @@ export const useCustomTariffsStore = create<CustomTariffsStore>()(
summaryPrice: {}, summaryPrice: {},
}), }),
{ {
name: "Custom tariffs store", name: "Custom tariffs",
enabled: process.env.NODE_ENV === "development", enabled: process.env.NODE_ENV === "development",
}), }),
{ {
@ -42,7 +42,7 @@ export const setCustomTariffsUserValue = (
value: number, value: number,
) => useCustomTariffsStore.setState( ) => useCustomTariffsStore.setState(
produce<CustomTariffsStore>(state => { produce<CustomTariffsStore>(state => {
if (!state.userValues[serviceKey]) state.userValues[serviceKey] = {}; state.userValues[serviceKey] ??= {};
state.userValues[serviceKey][privilegeId] = value; state.userValues[serviceKey][privilegeId] = value;
const sum = state.customTariffs[serviceKey].reduce((acc, tariff) => { const sum = state.customTariffs[serviceKey].reduce((acc, tariff) => {

@ -14,7 +14,7 @@ export const useMessageStore = create<MessageStore>()(
discounts: mockDiscounts discounts: mockDiscounts
}), }),
{ {
name: "Message store (marketplace)", name: "Discounts",
enabled: process.env.NODE_ENV === "development", enabled: process.env.NODE_ENV === "development",
} }
) )

@ -21,16 +21,16 @@ export const useMessageStore = create<MessageStore>()(
isPreventAutoscroll: false, isPreventAutoscroll: false,
}), }),
{ {
name: "Message store (marketplace)" name: "Messages"
} }
) )
); );
export const addOrUpdateMessages = (receivedMessages: TicketMessage[]) => { export const addOrUpdateMessages = (receivedMessages: TicketMessage[]) => {
const state = useMessageStore.getState(); const messages = useMessageStore.getState().messages;
const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {}; const messageIdToMessageMap: { [messageId: string]: TicketMessage; } = {};
[...state.messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message); [...messages, ...receivedMessages].forEach(message => messageIdToMessageMap[message.id] = message);
const sortedMessages = Object.values(messageIdToMessageMap).sort(sortMessagesByTime); const sortedMessages = Object.values(messageIdToMessageMap).sort(sortMessagesByTime);

@ -13,10 +13,34 @@ export const useTariffStore = create<TariffStore>()(
tariffs: [], tariffs: [],
}), }),
{ {
name: "Tariff store", name: "Tariffs",
enabled: process.env.NODE_ENV === "development", enabled: process.env.NODE_ENV === "development",
trace: true,
} }
) )
); );
export const setTariffs = (tariffs: Tariff[]) => useTariffStore.setState({tariffs}) export const updateTariffs = (tariffs: TariffStore["tariffs"]) => useTariffStore.setState(
state => {
const tariffMap: Record<string, Tariff> = {};
[...state.tariffs, ...tariffs].forEach(tariff => tariffMap[tariff._id] = tariff);
const sortedTariffs = Object.values(tariffMap).sort(sortTariffsByCreatedAt);
return { tariffs: sortedTariffs };
},
false,
{
type: "updateTariffs",
tariffsLength: tariffs.length,
}
);
function sortTariffsByCreatedAt(tariff1: Tariff, tariff2: Tariff) {
if (!tariff1.createdAt || !tariff2.createdAt) throw new Error("Trying to sort tariffs without createdAt field");
const date1 = new Date(tariff1.createdAt).getTime();
const date2 = new Date(tariff2.createdAt).getTime();
return date1 - date2;
}

@ -20,7 +20,7 @@ export const useTicketStore = create<TicketStore>()(
ticketsPerPage: 10, ticketsPerPage: 10,
}), }),
{ {
name: "Tickets store (marketplace)" name: "Tickets"
} }
) )
); );

@ -29,7 +29,7 @@ export const useUnauthTicketStore = create<UnauthTicketStore>()(
isPreventAutoscroll: false, isPreventAutoscroll: false,
}), }),
{ {
name: "Unauth ticket store" name: "Unauth tickets"
} }
), ),
{ {

@ -6,6 +6,7 @@ import { StringSchema, string } from "yup";
import { patchUser } from "@root/api/user"; import { patchUser } from "@root/api/user";
import { UserAccount, UserAccountSettingsFieldStatus, UserName, VerificationStatus } from "@root/model/account"; import { UserAccount, UserAccountSettingsFieldStatus, UserName, VerificationStatus } from "@root/model/account";
import { patchUserAccount } from "@root/api/account"; import { patchUserAccount } from "@root/api/account";
import { deleteCart, patchCart } from "@root/api/cart";
interface UserStore { interface UserStore {
@ -60,7 +61,7 @@ const initialState: UserStore = {
"ИНН": { ...defaultDocument }, "ИНН": { ...defaultDocument },
"Устав": { ...defaultDocument }, "Устав": { ...defaultDocument },
"Свидетельство о регистрации НКО": { ...defaultDocument }, "Свидетельство о регистрации НКО": { ...defaultDocument },
} },
}; };
export const useUserStore = create<UserStore>()( export const useUserStore = create<UserStore>()(
@ -68,8 +69,9 @@ export const useUserStore = create<UserStore>()(
devtools( devtools(
(set, get) => initialState, (set, get) => initialState,
{ {
name: "User store", name: "User",
enabled: process.env.NODE_ENV === "development", enabled: process.env.NODE_ENV === "development",
trace: true,
} }
), ),
{ {
@ -108,6 +110,17 @@ export const setUserAccount = (user: UserAccount) => useUserStore.setState(
state.settingsFields.secondname.value = user?.name.secondname ?? ""; state.settingsFields.secondname.value = user?.name.secondname ?? "";
state.settingsFields.middlename.value = user?.name.middlename ?? ""; state.settingsFields.middlename.value = user?.name.middlename ?? "";
state.settingsFields.orgname.value = user?.name.orgname ?? ""; state.settingsFields.orgname.value = user?.name.orgname ?? "";
}),
false,
{
type: "setUserAccount",
payload: user,
}
);
export const setCart = (cart: string[]) => useUserStore.setState(
produce<UserStore>(state => {
if (state.userAccount) state.userAccount.cart = cart;
}) })
); );
@ -203,7 +216,7 @@ export const setSettingsField = (
state.settingsFields[fieldName].error = errorMessage; state.settingsFields[fieldName].error = errorMessage;
state.settingsFields.hasError = Object.values(state.settingsFields).reduce((acc: boolean, field) => { state.settingsFields.hasError = Object.values(state.settingsFields).reduce((acc: boolean, field) => {
if (typeof field == "boolean") return acc; if (typeof field === "boolean") return acc;
if (field.error !== null) return true; if (field.error !== null) return true;
return acc; return acc;
@ -239,13 +252,20 @@ export const sendUserData = async () => {
orgname: state.settingsFields.orgname.value, orgname: state.settingsFields.orgname.value,
}; };
const [user, userAccount] = await Promise.all([ await Promise.all([
isPatchingUser && patchUser(userPayload), isPatchingUser && patchUser(userPayload),
isPatchingUserAccount && patchUserAccount(userAccountPayload), isPatchingUserAccount && patchUserAccount(userAccountPayload),
]); ]);
};
// if (user) setUser(user); export const addTariffToCart = async (tariffId: string) => {
// if (userAccount) setUserAccount(userAccount); const result = await patchCart(tariffId);
setCart(result);
};
export const removeTariffFromCart = async (tariffId: string) => {
const result = await deleteCart(tariffId);
setCart(result);
}; };
const validators: Record<UserSettingsField | keyof UserName, StringSchema> = { const validators: Record<UserSettingsField | keyof UserName, StringSchema> = {
@ -256,18 +276,18 @@ const validators: Record<UserSettingsField | keyof UserName, StringSchema> = {
skipAbsent: true, skipAbsent: true,
test(value, ctx) { test(value, ctx) {
if (value !== undefined) { if (value !== undefined) {
if (value.length == 0) return true if (value.length === 0) return true;
if (!/^[.,:;-_+\d\w]+$/.test(value)) { if (!/^[.,:;-_+\d\w]+$/.test(value)) {
return ctx.createError({ message: 'Некорректные символы в пароле' }) return ctx.createError({ message: 'Некорректные символы в пароле' });
} }
if (value.length > 0 && value.length < 8) { if (value.length > 0 && value.length < 8) {
return ctx.createError({ message: 'Минимум 8 символов' }) return ctx.createError({ message: 'Минимум 8 символов' });
} }
} }
return true return true;
} }
}), }),
// min(8, "Минимум 8 символов").matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы в пароле"), // min(8, "Минимум 8 символов").matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы в пароле"),
firstname: string(), firstname: string(),
secondname: string(), secondname: string(),
middlename: string(), middlename: string(),

55
src/utils/calcCart.ts Normal file

@ -0,0 +1,55 @@
import { mockDiscounts } from "@root/__mocks__/discounts";
import { CartData, PrivilegeCartData } from "@root/model/cart";
import { AnyDiscount } from "@root/model/discount";
import { Tariff } from "@root/model/tariff";
import { findPrivilegeDiscount, findServiceDiscount } from "./calcTariffPrices";
export function calcCart(tariffs: Tariff[], discounts: AnyDiscount[] = mockDiscounts): CartData {
const cartData: CartData = {
services: [],
priceBeforeDiscounts: 0,
priceAfterDiscounts: 0,
itemCount: 0,
};
tariffs.forEach(tariff => {
if (tariff.price && tariff.price > 0) cartData.priceBeforeDiscounts += tariff.price;
tariff.privilegies.forEach(privilege => {
let serviceData = cartData.services.find(service => service.serviceKey === privilege.serviceKey);
if (!serviceData) {
serviceData = {
serviceKey: privilege.serviceKey,
privileges: [],
price: 0,
};
cartData.services.push(serviceData);
}
let privilegePrice = privilege.amount * privilege.price;
if (!tariff.price) cartData.priceBeforeDiscounts += privilegePrice;
const privilegeDiscount = findPrivilegeDiscount(privilege, discounts);
if (privilegeDiscount) privilegePrice *= privilegeDiscount.target.products[0].factor;
const serviceDiscount = findServiceDiscount(privilege.serviceKey, privilegePrice, discounts);
if (serviceDiscount) privilegePrice *= serviceDiscount.target.factor;
const privilegeData: PrivilegeCartData = {
tariffId: tariff._id,
privilegeId: privilege.privilegeId,
name: privilege.description,
price: privilegePrice,
};
serviceData.privileges.push(privilegeData);
serviceData.price += privilegePrice;
cartData.priceAfterDiscounts += privilegePrice;
cartData.itemCount++;
});
});
return cartData;
}

@ -1,22 +1,22 @@
import { Tariff } from "@root/model/tariff"; import { Tariff } from "@root/model/tariff";
import { mockDiscounts } from "../__mocks__/discounts"; import { mockDiscounts } from "../__mocks__/discounts";
import { PrivilegeWithAmount } from "@root/model/privilege"; import { PrivilegeWithAmount } from "@root/model/privilege";
import { PrivilegeDiscount, ServiceDiscount } from "../model/discount"; import { AnyDiscount, PrivilegeDiscount, ServiceDiscount } from "../model/discount";
export function calcTariffPrices(tariff: Tariff): { export function calcTariffPrices(tariff: Tariff, discounts: AnyDiscount[] = mockDiscounts): {
price: number | undefined; price: number | undefined;
priceWithDiscounts: number | undefined; priceWithDiscounts: number | undefined;
} { } {
let price = tariff.price ?? tariff.privilegies.reduce((sum, privilege) => sum + privilege.amount * privilege.price, 0); let price = tariff.price || tariff.privilegies.reduce((sum, privilege) => sum + privilege.amount * privilege.price, 0);
const priceWithDiscounts = tariff.privilegies.reduce((sum, privilege) => { const priceWithDiscounts = tariff.privilegies.reduce((sum, privilege) => {
let privilegePrice = privilege.amount * privilege.price; let privilegePrice = privilege.amount * privilege.price;
const privilegeDiscount = findPrivilegeDiscount(privilege); const privilegeDiscount = findPrivilegeDiscount(privilege, discounts);
if (privilegeDiscount) privilegePrice *= privilegeDiscount.target.products[0].factor; if (privilegeDiscount) privilegePrice *= privilegeDiscount.target.products[0].factor;
const serviceDiscount = findServiceDiscount(privilege.serviceKey, privilegePrice); const serviceDiscount = findServiceDiscount(privilege.serviceKey, privilegePrice, discounts);
if (serviceDiscount) privilegePrice *= serviceDiscount.target.factor; if (serviceDiscount) privilegePrice *= serviceDiscount.target.factor;
return sum + privilegePrice; return sum + privilegePrice;
@ -28,8 +28,8 @@ export function calcTariffPrices(tariff: Tariff): {
}; };
} }
function findPrivilegeDiscount(privilege: PrivilegeWithAmount): PrivilegeDiscount | null { export function findPrivilegeDiscount(privilege: PrivilegeWithAmount, discounts: AnyDiscount[]): PrivilegeDiscount | null {
const applicableDiscounts = mockDiscounts.filter((discount): discount is PrivilegeDiscount => { const applicableDiscounts = discounts.filter((discount): discount is PrivilegeDiscount => {
return ( return (
discount.conditionType === "privilege" && discount.conditionType === "privilege" &&
privilege.privilegeId === discount.condition.privilege.id && privilege.privilegeId === discount.condition.privilege.id &&
@ -46,11 +46,12 @@ function findPrivilegeDiscount(privilege: PrivilegeWithAmount): PrivilegeDiscoun
return maxValueDiscount; return maxValueDiscount;
} }
function findServiceDiscount( export function findServiceDiscount(
serviceKey: string, serviceKey: string,
currentPrice: number, currentPrice: number,
discounts: AnyDiscount[],
): ServiceDiscount | null { ): ServiceDiscount | null {
const discountsForTariffService = mockDiscounts.filter((discount): discount is ServiceDiscount => { const discountsForTariffService = discounts.filter((discount): discount is ServiceDiscount => {
return ( return (
discount.conditionType === "service" && discount.conditionType === "service" &&
discount.condition.service.id === serviceKey && discount.condition.service.id === serviceKey &&

@ -0,0 +1,46 @@
import { devlog } from "@frontend/kitui";
import { getTariffById } from "@root/api/tariff";
import { useTariffStore } from "@root/stores/tariffs";
import { useUserStore } from "@root/stores/user";
import { useEffect } from "react";
import { addCartTariffs, removeMissingTariffs, setCartTariffStatus, useCartStore } from "@root/stores/cart";
import { Tariff } from "@root/model/tariff";
export function useCart() {
const tariffs = useTariffStore(state => state.tariffs);
const cartTariffMap = useCartStore(state => state.cartTariffMap);
const cartTariffIds = useUserStore(state => state.userAccount?.cart);
const cart = useCartStore(state => state.cart);
useEffect(function addTariffsToCart() {
const knownTariffs: Tariff[] = [];
cartTariffIds?.forEach(tariffId => {
if (typeof cartTariffMap[tariffId] === "object") return;
const tariff = tariffs.find(tariff => tariff._id === tariffId);
if (tariff) return knownTariffs.push(tariff);
if (!cartTariffMap[tariffId]) {
setCartTariffStatus(tariffId, "loading");
getTariffById(tariffId).then(tariff => {
devlog("Unlisted tariff", tariff);
addCartTariffs([tariff]);
}).catch(error => {
devlog(`Error fetching unlisted tariff ${tariffId}`, error);
setCartTariffStatus(tariffId, "not found");
});
}
});
if (knownTariffs.length > 0) addCartTariffs(knownTariffs);
}, [cartTariffIds, cartTariffMap, tariffs]);
useEffect(function cleanUpCart() {
if (cartTariffIds) removeMissingTariffs(cartTariffIds);
}, [cartTariffIds]);
return cart;
}

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