fix: auth and cart API

This commit is contained in:
IlyaDoronin 2023-08-30 12:56:14 +03:00
parent 731157322a
commit 5ef7adc198
9 changed files with 598 additions and 484 deletions

@ -1,13 +1,23 @@
import { makeRequest } from "@frontend/kitui"; import { makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
const apiUrl = process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital"; const apiUrl =
process.env.NODE_ENV === "production" ? "" : "https://hub.pena.digital";
export function logout() { export async function logout(): Promise<[unknown, string?]> {
return makeRequest<never, void>({ try {
url: apiUrl + "/auth/logout", const logoutResponse = await makeRequest<never, void>({
method: "POST", url: apiUrl + "/auth/logout",
useToken: true, method: "POST",
withCredentials: true, useToken: true,
withCredentials: true,
}); });
}
return [logoutResponse];
} catch (nativeError) {
const error = parseAxiosError(nativeError);
return [null, `Не удалось выйти. ${error}`];
}
}

@ -1,39 +1,84 @@
import { UserAccount, makeRequest } from "@frontend/kitui"; import { UserAccount, makeRequest } from "@frontend/kitui";
import { parseAxiosError } from "@root/utils/parse-error";
const apiUrl = process.env.NODE_ENV === "production" ? "/customer" : "https://hub.pena.digital/customer"; const apiUrl =
process.env.NODE_ENV === "production"
? "/customer"
: "https://hub.pena.digital/customer";
export function patchCart(tariffId: string) { export async function patchCart(
return makeRequest<never, string[]>({ tariffId: string
url: apiUrl + `/cart?id=${tariffId}`, ): Promise<[string[], string?]> {
method: "PATCH", try {
useToken: true, const patchCartResponse = await makeRequest<never, string[]>({
url: apiUrl + `/cart?id=${tariffId}`,
method: "PATCH",
useToken: true,
}); });
return [patchCartResponse];
} catch (nativeError) {
const error = parseAxiosError(nativeError);
return [[], `Не удалось добавить товар в корзину. ${error}`];
}
} }
export function deleteCart(tariffId: string) { export async function deleteCart(
return makeRequest<never, string[]>({ tariffId: string
url: apiUrl + `/cart?id=${tariffId}`, ): Promise<[string[], string?]> {
method: "DELETE", try {
useToken: true, const deleteCartResponse = await makeRequest<never, string[]>({
url: apiUrl + `/cart?id=${tariffId}`,
method: "DELETE",
useToken: true,
}); });
return [deleteCartResponse];
} catch (nativeError) {
const error = parseAxiosError(nativeError);
return [[], `Не удалось удалить товар из корзины. ${error}`];
}
} }
export function payCart() { export async function payCart(): Promise<[UserAccount | null, string?]> {
return makeRequest<never, UserAccount>({ try {
url: apiUrl + "/cart/pay", const payCartResponse = await makeRequest<never, UserAccount>({
method: "POST", url: apiUrl + "/cart/pay",
useToken: true, method: "POST",
useToken: true,
}); });
return [payCartResponse];
} catch (nativeError) {
const error = parseAxiosError(nativeError);
return [null, `Не удалось оплатить товар из корзины. ${error}`];
}
} }
export function patchCurrency(currency: string) { export async function patchCurrency(
return makeRequest<{ currency: string; }, UserAccount>({ currency: string
url: apiUrl + "/wallet", ): Promise<[UserAccount | null, string?]> {
method: "PATCH", try {
useToken: true, const patchCurrencyResponse = await makeRequest<
body: { { currency: string },
currency UserAccount
}, >({
url: apiUrl + "/wallet",
method: "PATCH",
useToken: true,
body: {
currency,
},
}); });
return [patchCurrencyResponse];
} catch (nativeError) {
const error = parseAxiosError(nativeError);
return [null, `Не удалось изменить валюту. ${error}`];
}
} }

@ -1,12 +1,25 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Typography, Drawer, useMediaQuery, useTheme, Box, IconButton, Badge, Button } from "@mui/material"; import {
Typography,
Drawer,
useMediaQuery,
useTheme,
Box,
IconButton,
Badge,
Button,
} from "@mui/material";
import { getMessageFromFetchError } from "@frontend/kitui"; import { getMessageFromFetchError } from "@frontend/kitui";
import SectionWrapper from "./SectionWrapper"; import SectionWrapper from "./SectionWrapper";
import CustomWrapperDrawer from "./CustomWrapperDrawer"; import CustomWrapperDrawer from "./CustomWrapperDrawer";
import { NotificationsModal } from "./NotificationsModal"; import { NotificationsModal } from "./NotificationsModal";
import { useCart } from "@root/utils/hooks/useCart"; import { useCart } from "@root/utils/hooks/useCart";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { closeCartDrawer, openCartDrawer, useCartStore } from "@root/stores/cart"; import {
closeCartDrawer,
openCartDrawer,
useCartStore,
} from "@root/stores/cart";
import { setUserAccount, useUserStore } from "@root/stores/user"; import { setUserAccount, useUserStore } from "@root/stores/user";
import { useTicketStore } from "@root/stores/tickets"; import { useTicketStore } from "@root/stores/tickets";
@ -20,7 +33,8 @@ import { enqueueSnackbar } from "notistack";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
export default function Drawers() { export default function Drawers() {
const [openNotificationsModal, setOpenNotificationsModal] = useState<boolean>(false); const [openNotificationsModal, setOpenNotificationsModal] =
useState<boolean>(false);
const bellRef = useRef<HTMLButtonElement | null>(null); const bellRef = useRef<HTMLButtonElement | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
@ -33,263 +47,273 @@ export default function Drawers() {
const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState<number>(0); const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState<number>(0);
const notificationsCount = tickets.filter( const notificationsCount = tickets.filter(
({ user, top_message }) => user !== top_message.user_id && top_message.shown.me !== 1 ({ user, top_message }) =>
user !== top_message.user_id && top_message.shown.me !== 1
).length; ).length;
function handlePayClick() { async function handlePayClick() {
payCart() const [payCartResponse, payCartError] = await payCart();
.then((result) => {
setUserAccount(result); if (payCartError) {
}) const notEnoughMoneyAmount = parseInt(
.catch((error) => { payCartError.replace("insufficient funds: ", "")
if (isAxiosError(error) && error.response?.status === 402) { );
const notEnoughMoneyAmount = parseInt(
(error.response.data.message as string).replace("insufficient funds: ", "") setNotEnoughMoneyAmount(notEnoughMoneyAmount);
);
setNotEnoughMoneyAmount(notEnoughMoneyAmount); return enqueueSnackbar(payCartError);
} else { }
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message); if (payCartResponse) {
} setUserAccount(payCartResponse);
}); }
} }
function handleReplenishWallet() { function handleReplenishWallet() {
navigate("/payment", { state: { notEnoughMoneyAmount } }); navigate("/payment", { state: { notEnoughMoneyAmount } });
} }
return ( return (
<Box sx={{ display: "flex", gap: isTablet ? "10px" : "20px" }}> <Box sx={{ display: "flex", gap: isTablet ? "10px" : "20px" }}>
<IconButton <IconButton
ref={bellRef} ref={bellRef}
aria-label="cart" aria-label="cart"
onClick={() => setOpenNotificationsModal((isOpened) => !isOpened)} onClick={() => setOpenNotificationsModal((isOpened) => !isOpened)}
sx={{ sx={{
cursor: "pointer", cursor: "pointer",
borderRadius: "6px", borderRadius: "6px",
background: openNotificationsModal background: openNotificationsModal
? theme.palette.purple.main ? theme.palette.purple.main
: theme.palette.background.default, : theme.palette.background.default,
"& .MuiBadge-badge": { "& .MuiBadge-badge": {
background: openNotificationsModal background: openNotificationsModal
? theme.palette.background.default ? theme.palette.background.default
: theme.palette.purple.main, : theme.palette.purple.main,
color: openNotificationsModal color: openNotificationsModal
? theme.palette.purple.main ? theme.palette.purple.main
: theme.palette.background.default, : theme.palette.background.default,
}, },
"& svg > path:first-child": { "& svg > path:first-child": {
fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
}, },
"& svg > path:last-child": { "& svg > path:last-child": {
stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF",
}, },
"&:hover": { "&:hover": {
background: theme.palette.purple.main, background: theme.palette.purple.main,
"& .MuiBox-root": { "& .MuiBox-root": {
background: theme.palette.purple.main, background: theme.palette.purple.main,
}, },
"& .MuiBadge-badge": { "& .MuiBadge-badge": {
background: theme.palette.background.default, background: theme.palette.background.default,
color: theme.palette.purple.main, color: theme.palette.purple.main,
}, },
"& svg > path:first-child": { fill: "#FFFFFF" }, "& svg > path:first-child": { fill: "#FFFFFF" },
"& svg > path:last-child": { stroke: "#FFFFFF" }, "& svg > path:last-child": { stroke: "#FFFFFF" },
}, },
}} }}
>
<Badge
badgeContent={notificationsCount}
sx={{
"& .MuiBadge-badge": {
display: notificationsCount ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<BellIcon />
</Badge>
</IconButton>
<NotificationsModal
open={openNotificationsModal}
setOpen={setOpenNotificationsModal}
anchorElement={bellRef.current}
notifications={tickets
.filter(({ user, top_message }) => user !== top_message.user_id)
.map((ticket) => ({
text: "У вас новое сообщение от техподдержки",
date: new Date(ticket.updated_at).toLocaleDateString(),
url: `/support/${ticket.id}`,
watched:
ticket.user === ticket.top_message.user_id ||
ticket.top_message.shown.me === 1,
}))}
/>
<IconButton
onClick={openCartDrawer}
component="div"
sx={{
cursor: "pointer",
background: theme.palette.background.default,
borderRadius: "6px",
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:nth-child(1)": { fill: "#FFFFFF" },
"& svg > path:nth-child(2)": { fill: "#FFFFFF" },
"& svg > path:nth-child(3)": { stroke: "#FFFFFF" },
},
}}
>
<Badge
badgeContent={userAccount?.cart.length}
sx={{
"& .MuiBadge-badge": {
display: userAccount?.cart.length ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<CartIcon />
</Badge>
</IconButton>
<Drawer
anchor={"right"}
open={isDrawerOpen}
onClose={closeCartDrawer}
sx={{ background: "rgba(0, 0, 0, 0.55)" }}
>
<SectionWrapper
maxWidth="lg"
sx={{
pl: "0px",
pr: "0px",
width: "450px",
}}
>
<Box
sx={{
width: "100%",
pt: "12px",
pb: "12px",
display: "flex",
justifyContent: "space-between",
bgcolor: "#F2F3F7",
gap: "10px",
pl: "20px",
pr: "20px",
}}
>
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
> >
<Badge Корзина
badgeContent={notificationsCount} </Typography>
sx={{ <IconButton onClick={closeCartDrawer} sx={{ p: 0 }}>
"& .MuiBadge-badge": { <CrossIcon />
display: notificationsCount ? "flex" : "none",
color: "#FFFFFF",
background: theme.palette.purple.main,
transform: "scale(0.8) translate(50%, -50%)",
top: "2px",
right: "2px",
fontWeight: 400,
},
}}
>
<BellIcon />
</Badge>
</IconButton> </IconButton>
<NotificationsModal </Box>
open={openNotificationsModal} <Box sx={{ pl: "20px", pr: "20px" }}>
setOpen={setOpenNotificationsModal} {cart.services.map((serviceData) => (
anchorElement={bellRef.current} <CustomWrapperDrawer
notifications={tickets key={serviceData.serviceKey}
.filter(({ user, top_message }) => user !== top_message.user_id) serviceData={serviceData}
.map((ticket) => ({ />
text: "У вас новое сообщение от техподдержки", ))}
date: new Date(ticket.updated_at).toLocaleDateString(), <Box
url: `/support/${ticket.id}`, sx={{
watched: mt: "40px",
ticket.user === ticket.top_message.user_id || pt: upMd ? "30px" : undefined,
ticket.top_message.shown.me === 1, borderTop: upMd
}))} ? `1px solid ${theme.palette.gray.main}`
/> : undefined,
<IconButton }}
onClick={openCartDrawer}
component="div"
sx={{
cursor: "pointer",
background: theme.palette.background.default,
borderRadius: "6px",
"&:hover": {
background: theme.palette.purple.main,
"& .MuiBox-root": {
background: theme.palette.purple.main,
},
"& .MuiBadge-badge": {
background: theme.palette.background.default,
color: theme.palette.purple.main,
},
"& svg > path:nth-child(1)": { fill: "#FFFFFF" },
"& svg > path:nth-child(2)": { fill: "#FFFFFF" },
"& svg > path:nth-child(3)": { stroke: "#FFFFFF" },
},
}}
> >
<Badge <Box
badgeContent={userAccount?.cart.length} sx={{
sx={{ width: upMd ? "100%" : undefined,
"& .MuiBadge-badge": { display: "flex",
display: userAccount?.cart.length ? "flex" : "none", flexWrap: "wrap",
color: "#FFFFFF", flexDirection: "column",
background: theme.palette.purple.main, }}
transform: "scale(0.8) translate(50%, -50%)", >
top: "2px", <Typography variant="h4" mb={upMd ? "18px" : "30px"}>
right: "2px", Итоговая цена
fontWeight: 400, </Typography>
}, <Typography color={theme.palette.gray.dark}>
}} Текст-заполнитель это текст, который имеет Текст-заполнитель
это текст, который имеет Текст-заполнитель это текст,
который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель
</Typography>
</Box>
<Box
sx={{
color: theme.palette.gray.dark,
pb: "100px",
pt: "38px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: upMd ? "column" : "row",
alignItems: upMd ? "start" : "center",
mt: upMd ? "10px" : "30px",
gap: "15px",
}}
> >
<CartIcon /> <Typography
</Badge> color={theme.palette.orange.main}
</IconButton>
<Drawer anchor={"right"} open={isDrawerOpen} onClose={closeCartDrawer} sx={{ background: "rgba(0, 0, 0, 0.55)" }}>
<SectionWrapper
maxWidth="lg"
sx={{ sx={{
pl: "0px", textDecoration: "line-through",
pr: "0px", order: upMd ? 1 : 2,
width: "450px",
}} }}
>
{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>
<Button
variant="pena-contained-dark"
component={Link}
to="/cart"
onClick={
notEnoughMoneyAmount === 0
? handlePayClick
: handleReplenishWallet
}
sx={{ mt: "25px" }}
> >
<Box Оплатить
sx={{ </Button>
width: "100%", </Box>
pt: "12px", </Box>
pb: "12px", </Box>
display: "flex", </SectionWrapper>
justifyContent: "space-between", </Drawer>
bgcolor: "#F2F3F7", </Box>
gap: "10px", );
pl: "20px",
pr: "20px",
}}
>
<Typography
component="div"
sx={{
fontSize: "18px",
lineHeight: "21px",
font: "Rubick",
}}
>
Корзина
</Typography>
<IconButton onClick={closeCartDrawer} sx={{ p: 0 }}>
<CrossIcon />
</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.gray.main}`
: undefined,
}}
>
<Box
sx={{
width: upMd ? "100%" : undefined,
display: "flex",
flexWrap: "wrap",
flexDirection: "column",
}}
>
<Typography variant="h4" mb={upMd ? "18px" : "30px"}>
Итоговая цена
</Typography>
<Typography color={theme.palette.gray.dark}>
Текст-заполнитель это текст, который имеет Текст-заполнитель
это текст, который имеет Текст-заполнитель это текст,
который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель
</Typography>
</Box>
<Box
sx={{
color: theme.palette.gray.dark,
pb: "100px",
pt: "38px",
}}
>
<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>
<Button
variant="pena-contained-dark"
component={Link}
to="/cart"
onClick={notEnoughMoneyAmount === 0 ? handlePayClick : handleReplenishWallet}
sx={{ mt: "25px" }}
>
Оплатить
</Button>
</Box>
</Box>
</Box>
</SectionWrapper>
</Drawer>
</Box>
);
} }

@ -1,103 +1,94 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { import { Box, Container, Typography, useTheme } from "@mui/material";
Box, Container,
Typography,
useTheme
} from "@mui/material";
import Drawers from "../Drawers"; import Drawers from "../Drawers";
import PenaLogo from "../PenaLogo"; import PenaLogo from "../PenaLogo";
import Menu from "../Menu"; import Menu from "../Menu";
import { logout } from "@root/api/auth"; import { logout } from "@root/api/auth";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { clearUserData, useUserStore } from "@root/stores/user"; import { clearUserData, useUserStore } from "@root/stores/user";
import { AvatarButton, LogoutButton, WalletButton, clearAuthToken, getMessageFromFetchError } from "@frontend/kitui"; import {
AvatarButton,
LogoutButton,
WalletButton,
clearAuthToken,
} from "@frontend/kitui";
import { clearCustomTariffs } from "@root/stores/customTariffs"; import { clearCustomTariffs } from "@root/stores/customTariffs";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { clearTickets } from "@root/stores/tickets";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
} }
export default function NavbarFull({ children }: Props) { export default function NavbarFull({ children }: Props) {
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0;
const initials = useUserStore(state => state.initials); const initials = useUserStore((state) => state.initials);
async function handleLogoutClick() { async function handleLogoutClick() {
try { const [_, logoutError] = await logout();
await logout();
clearAuthToken(); if (logoutError) {
clearUserData(); return enqueueSnackbar(logoutError);
clearCustomTariffs();
navigate("/");
} catch (error: any) {
const message = getMessageFromFetchError(error, "Не удалось выйти");
if (message) enqueueSnackbar(message);
}
} }
return ( clearAuthToken();
<Box> clearUserData();
<Container clearCustomTariffs();
component="nav" clearTickets();
disableGutters navigate("/");
maxWidth={false} }
sx={{
zIndex: 1, return (
position: "fixed", <Box>
top: "0", <Container
px: "16px", component="nav"
display: "flex", disableGutters
height: "80px", maxWidth={false}
alignItems: "center", sx={{
gap: "60px", zIndex: 1,
bgcolor: "white", position: "fixed",
borderBottom: "1px solid #E3E3E3", top: "0",
}} px: "16px",
display: "flex",
height: "80px",
alignItems: "center",
gap: "60px",
bgcolor: "white",
borderBottom: "1px solid #E3E3E3",
}}
>
<Link to="/">
<PenaLogo width={124} color="black" />
</Link>
<Menu />
<Box sx={{ display: "flex", ml: "auto" }}>
<Drawers />
<WalletButton component={Link} to="/wallet" sx={{ ml: "20px" }} />
<Box sx={{ ml: "8px", whiteSpace: "nowrap" }}>
<Typography
sx={{
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.gray.dark,
}}
> >
<Link to="/"> Мой баланс
<PenaLogo width={124} color="black" /> </Typography>
</Link> <Typography variant="body2" color={theme.palette.purple.main}>
<Menu /> {currencyFormatter.format(cash / 100)}
<Box sx={{ display: "flex", ml: "auto" }}> </Typography>
<Drawers /> </Box>
<WalletButton <AvatarButton component={Link} to="/settings" sx={{ ml: "27px" }}>
component={Link} {initials}
to="/wallet" </AvatarButton>
sx={{ ml: "20px" }} <LogoutButton onClick={handleLogoutClick} sx={{ ml: "20px" }} />
/>
<Box sx={{ ml: "8px", whiteSpace: "nowrap" }}>
<Typography
sx={{
fontSize: "12px",
lineHeight: "14px",
color: theme.palette.gray.dark,
}}
>
Мой баланс
</Typography>
<Typography
variant="body2"
color={theme.palette.purple.main}
>
{currencyFormatter.format(cash / 100)}
</Typography>
</Box>
<AvatarButton
component={Link}
to="/settings"
sx={{ ml: "27px" }}
>{initials}</AvatarButton>
<LogoutButton
onClick={handleLogoutClick}
sx={{ ml: "20px" }}
/>
</Box>
</Container>
<Box>{children}</Box>
</Box> </Box>
); </Container>
<Box>{children}</Box>
</Box>
);
} }

@ -1,17 +1,17 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { import {
Box, Box,
IconButton, IconButton,
Typography, Typography,
useTheme, useTheme,
useMediaQuery, useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import LogoutIcon from "../icons/LogoutIcon"; import LogoutIcon from "../icons/LogoutIcon";
import Drawers from "../Drawers"; import Drawers from "../Drawers";
import { logout } from "@root/api/auth"; import { logout } from "@root/api/auth";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { clearUserData, useUserStore } from "@root/stores/user"; import { clearUserData, useUserStore } from "@root/stores/user";
import { AvatarButton, clearAuthToken, getMessageFromFetchError } from "@frontend/kitui"; import { AvatarButton, clearAuthToken } from "@frontend/kitui";
import { clearCustomTariffs } from "@root/stores/customTariffs"; import { clearCustomTariffs } from "@root/stores/customTariffs";
import { clearTickets } from "@root/stores/tickets"; import { clearTickets } from "@root/stores/tickets";
@ -20,74 +20,72 @@ import { currencyFormatter } from "@root/utils/currencyFormatter";
import walletIcon from "@root/assets/Icons/wallet_icon.svg"; import walletIcon from "@root/assets/Icons/wallet_icon.svg";
export const NavbarPanel = () => { export const NavbarPanel = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0;
const initials = useUserStore(state => state.initials); const initials = useUserStore((state) => state.initials);
async function handleLogoutClick() { async function handleLogoutClick() {
try { const [_, logoutError] = await logout();
await logout();
clearAuthToken(); if (logoutError) {
clearUserData(); return enqueueSnackbar(logoutError);
clearCustomTariffs();
clearTickets();
navigate("/");
} catch (error: any) {
const message = getMessageFromFetchError(error, "Не удалось выйти");
if (message) enqueueSnackbar(message);
}
} }
return ( clearAuthToken();
<Box sx={{ display: "flex", ml: "auto" }}> clearUserData();
<Drawers /> clearCustomTariffs();
<IconButton clearTickets();
sx={{ navigate("/");
display: "flex", }
alignItems: "center",
ml: isTablet ? "10px" : "20px", return (
bgcolor: "#F2F3F7", <Box sx={{ display: "flex", ml: "auto" }}>
borderRadius: "6px", <Drawers />
height: "36px", <IconButton
width: "36px", sx={{
}} display: "flex",
onClick={() => navigate("/wallet")} alignItems: "center",
> ml: isTablet ? "10px" : "20px",
<img src={walletIcon} alt="wallet" /> bgcolor: "#F2F3F7",
</IconButton> borderRadius: "6px",
<Box sx={{ ml: "8px", whiteSpace: "nowrap" }}> height: "36px",
<Typography width: "36px",
sx={{ }}
fontSize: "12px", onClick={() => navigate("/wallet")}
lineHeight: "14px", >
color: theme.palette.gray.dark, <img src={walletIcon} alt="wallet" />
}} </IconButton>
> <Box sx={{ ml: "8px", whiteSpace: "nowrap" }}>
Мой баланс <Typography
</Typography> sx={{
<Typography variant="body2" color={theme.palette.purple.main}> fontSize: "12px",
{currencyFormatter.format(cash / 100)} lineHeight: "14px",
</Typography> color: theme.palette.gray.dark,
</Box> }}
<AvatarButton >
component={Link} Мой баланс
to="/settings" </Typography>
sx={{ ml: "27px" }} <Typography variant="body2" color={theme.palette.purple.main}>
>{initials}</AvatarButton> {currencyFormatter.format(cash / 100)}
<IconButton </Typography>
onClick={handleLogoutClick} </Box>
sx={{ <AvatarButton component={Link} to="/settings" sx={{ ml: "27px" }}>
ml: "20px", {initials}
bgcolor: "#F2F3F7", </AvatarButton>
borderRadius: "6px", <IconButton
height: "36px", onClick={handleLogoutClick}
width: "36px", sx={{
}} ml: "20px",
> bgcolor: "#F2F3F7",
<LogoutIcon /> borderRadius: "6px",
</IconButton> height: "36px",
</Box> width: "36px",
); }}
>
<LogoutIcon />
</IconButton>
</Box>
);
}; };

@ -1,6 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Box, Button, Container, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material"; import {
Box,
Button,
Container,
IconButton,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import SectionWrapper from "../SectionWrapper"; import SectionWrapper from "../SectionWrapper";
import LogoutIcon from "../icons/LogoutIcon"; import LogoutIcon from "../icons/LogoutIcon";
import WalletIcon from "../icons/WalletIcon"; import WalletIcon from "../icons/WalletIcon";
@ -11,13 +19,17 @@ import Menu from "../Menu";
import { logout } from "@root/api/auth"; import { logout } from "@root/api/auth";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { clearUserData, useUserStore } from "@root/stores/user"; import { clearUserData, useUserStore } from "@root/stores/user";
import { BurgerButton, clearAuthToken, getMessageFromFetchError } from "@frontend/kitui"; import {
BurgerButton,
clearAuthToken,
getMessageFromFetchError,
} from "@frontend/kitui";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { clearCustomTariffs } from "@root/stores/customTariffs"; import { clearCustomTariffs } from "@root/stores/customTariffs";
import { clearTickets } from "@root/stores/tickets"; import { clearTickets } from "@root/stores/tickets";
interface Props { interface Props {
isLoggedIn: boolean; isLoggedIn: boolean;
} }
export default function NavbarFull({ isLoggedIn }: Props) { export default function NavbarFull({ isLoggedIn }: Props) {
@ -30,17 +42,17 @@ export default function NavbarFull({ isLoggedIn }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
async function handleLogoutClick() { async function handleLogoutClick() {
try { const [_, logoutError] = await logout();
await logout();
clearAuthToken(); if (logoutError) {
clearUserData(); return enqueueSnackbar(logoutError);
clearCustomTariffs();
clearTickets();
navigate("/");
} catch (error: any) {
const message = getMessageFromFetchError(error, "Не удалось выйти");
if (message) enqueueSnackbar(message);
} }
clearAuthToken();
clearUserData();
clearCustomTariffs();
clearTickets();
navigate("/");
} }
return isLoggedIn ? ( return isLoggedIn ? (
@ -70,7 +82,10 @@ export default function NavbarFull({ isLoggedIn }: Props) {
}} }}
> >
<Drawers /> <Drawers />
<IconButton sx={{ p: 0, ml: "8px" }} onClick={() => navigate("/wallet")}> <IconButton
sx={{ p: 0, ml: "8px" }}
onClick={() => navigate("/wallet")}
>
<WalletIcon color={theme.palette.gray.main} bgcolor="#F2F3F7" /> <WalletIcon color={theme.palette.gray.main} bgcolor="#F2F3F7" />
</IconButton> </IconButton>
<Box sx={{ ml: "8px", whiteSpace: "nowrap" }}> <Box sx={{ ml: "8px", whiteSpace: "nowrap" }}>
@ -134,7 +149,10 @@ export default function NavbarFull({ isLoggedIn }: Props) {
> >
Личный кабинет Личный кабинет
</Button> </Button>
<BurgerButton onClick={() => setOpen(!open)} sx={{ color: "white", display: !isTablet ? "block" : "none" }} /> <BurgerButton
onClick={() => setOpen(!open)}
sx={{ color: "white", display: !isTablet ? "block" : "none" }}
/>
</SectionWrapper> </SectionWrapper>
</> </>
); );

@ -1,4 +1,11 @@
import { Alert, Box, Button, Typography, useMediaQuery, useTheme } from "@mui/material"; import {
Alert,
Box,
Button,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { currencyFormatter } from "@root/utils/currencyFormatter"; import { currencyFormatter } from "@root/utils/currencyFormatter";
import { payCart } from "@root/api/cart"; import { payCart } from "@root/api/cart";
import { setUserAccount } from "@root/stores/user"; import { setUserAccount } from "@root/stores/user";
@ -13,29 +20,32 @@ interface Props {
priceAfterDiscounts: number; priceAfterDiscounts: number;
} }
export default function TotalPrice({ priceAfterDiscounts, priceBeforeDiscounts }: Props) { export default function TotalPrice({
priceAfterDiscounts,
priceBeforeDiscounts,
}: Props) {
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const isMobile = useMediaQuery(theme.breakpoints.down(550)); const isMobile = useMediaQuery(theme.breakpoints.down(550));
const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState<number>(0); const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState<number>(0);
const navigate = useNavigate(); const navigate = useNavigate();
function handlePayClick() { async function handlePayClick() {
payCart() const [payCartResponse, payCartError] = await payCart();
.then((result) => {
setUserAccount(result); if (payCartError) {
}) const notEnoughMoneyAmount = parseInt(
.catch((error) => { payCartError.replace("insufficient funds: ", "")
if (isAxiosError(error) && error.response?.status === 402) { );
const notEnoughMoneyAmount = parseInt(
(error.response.data.message as string).replace("insufficient funds: ", "") setNotEnoughMoneyAmount(notEnoughMoneyAmount);
);
setNotEnoughMoneyAmount(notEnoughMoneyAmount); return enqueueSnackbar(payCartError);
} else { }
const message = getMessageFromFetchError(error);
if (message) enqueueSnackbar(message); if (payCartResponse) {
} setUserAccount(payCartResponse);
}); }
} }
function handleReplenishWallet() { function handleReplenishWallet() {
@ -65,8 +75,9 @@ export default function TotalPrice({ priceAfterDiscounts, priceBeforeDiscounts }
Итоговая цена Итоговая цена
</Typography> </Typography>
<Typography color={theme.palette.gray.main}> <Typography color={theme.palette.gray.main}>
Текст-заполнитель это текст, который имеет Текст-заполнитель это текст, который имеет Текст-заполнитель Текст-заполнитель это текст, который имеет Текст-заполнитель это
это текст, который имеет Текст-заполнитель это текст, который имеет Текст-заполнитель текст, который имеет Текст-заполнитель это текст, который имеет
Текст-заполнитель это текст, который имеет Текст-заполнитель
</Typography> </Typography>
</Box> </Box>
<Box <Box
@ -108,9 +119,13 @@ export default function TotalPrice({ priceAfterDiscounts, priceBeforeDiscounts }
)} )}
<Button <Button
variant="pena-contained-dark" variant="pena-contained-dark"
onClick={notEnoughMoneyAmount === 0 ? handlePayClick : handleReplenishWallet} onClick={
notEnoughMoneyAmount === 0 ? handlePayClick : handleReplenishWallet
}
sx={{ mt: "10px" }} sx={{ mt: "10px" }}
>{notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"}</Button> >
{notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"}
</Button>
</Box> </Box>
</Box> </Box>
); );

@ -293,18 +293,29 @@ export const sendUserData = async () => {
}; };
export const addTariffToCart = async (tariffId: string) => { export const addTariffToCart = async (tariffId: string) => {
const result = await patchCart(tariffId); const [patchCartResponse, patchCartError] = await patchCart(tariffId);
setCart(result);
if (!patchCartError) {
setCart(patchCartResponse);
}
}; };
export const removeTariffFromCart = async (tariffId: string) => { export const removeTariffFromCart = async (tariffId: string) => {
const result = await deleteCart(tariffId); const [deleteCartResponse, deleteCartError] = await deleteCart(tariffId);
setCart(result);
if (!deleteCartError) {
setCart(deleteCartResponse);
}
}; };
export const changeUserCurrency = async (currency: string) => { export const changeUserCurrency = async (currency: string) => {
const result = await patchCurrency(currency); const [patchCurrencyResponse, patchCurrencyError] = await patchCurrency(
setUserAccount(result); currency
);
if (!patchCurrencyError && patchCurrencyResponse) {
setUserAccount(patchCurrencyResponse);
}
}; };
const validators: Record<UserSettingsField | keyof UserName, StringSchema> = { const validators: Record<UserSettingsField | keyof UserName, StringSchema> = {

@ -1,23 +1,25 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
const parseAxiosError = (error: AxiosError): string => { export const parseAxiosError = (nativeError: unknown): string => {
const error = nativeError as AxiosError;
switch (error.status) { switch (error.status) {
case 404: case 404:
return "Не найдено"; return "Не найдено.";
case 403: case 403:
return "Доступ ограничен"; return "Доступ ограничен.";
case 401: case 401:
return "Ошибка авторизации"; return "Ошибка авторизации.";
case 500: case 500:
return "Внутренняя ошибка сервера"; return "Внутренняя ошибка сервера.";
case 503: case 503:
return "Сервис недоступен"; return "Сервис недоступен.";
default: default:
return "Неизвестная ошибка сервера"; return "Неизвестная ошибка сервера.";
} }
}; };