diff --git a/src/api/auth.ts b/src/api/auth.ts index 1da42ae..a8acc5c 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,13 +1,23 @@ 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() { - return makeRequest({ - url: apiUrl + "/auth/logout", - method: "POST", - useToken: true, - withCredentials: true, +export async function logout(): Promise<[unknown, string?]> { + try { + const logoutResponse = await makeRequest({ + url: apiUrl + "/auth/logout", + method: "POST", + useToken: true, + withCredentials: true, }); -} \ No newline at end of file + + return [logoutResponse]; + } catch (nativeError) { + const error = parseAxiosError(nativeError); + + return [null, `Не удалось выйти. ${error}`]; + } +} diff --git a/src/api/cart.ts b/src/api/cart.ts index cf3b5fb..42f66c3 100644 --- a/src/api/cart.ts +++ b/src/api/cart.ts @@ -1,39 +1,84 @@ 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) { - return makeRequest({ - url: apiUrl + `/cart?id=${tariffId}`, - method: "PATCH", - useToken: true, +export async function patchCart( + tariffId: string +): Promise<[string[], string?]> { + try { + const patchCartResponse = await makeRequest({ + url: apiUrl + `/cart?id=${tariffId}`, + method: "PATCH", + useToken: true, }); + + return [patchCartResponse]; + } catch (nativeError) { + const error = parseAxiosError(nativeError); + + return [[], `Не удалось добавить товар в корзину. ${error}`]; + } } -export function deleteCart(tariffId: string) { - return makeRequest({ - url: apiUrl + `/cart?id=${tariffId}`, - method: "DELETE", - useToken: true, +export async function deleteCart( + tariffId: string +): Promise<[string[], string?]> { + try { + const deleteCartResponse = await makeRequest({ + url: apiUrl + `/cart?id=${tariffId}`, + method: "DELETE", + useToken: true, }); + + return [deleteCartResponse]; + } catch (nativeError) { + const error = parseAxiosError(nativeError); + + return [[], `Не удалось удалить товар из корзины. ${error}`]; + } } -export function payCart() { - return makeRequest({ - url: apiUrl + "/cart/pay", - method: "POST", - useToken: true, +export async function payCart(): Promise<[UserAccount | null, string?]> { + try { + const payCartResponse = await makeRequest({ + url: apiUrl + "/cart/pay", + method: "POST", + useToken: true, }); + + return [payCartResponse]; + } catch (nativeError) { + const error = parseAxiosError(nativeError); + + return [null, `Не удалось оплатить товар из корзины. ${error}`]; + } } -export function patchCurrency(currency: string) { - return makeRequest<{ currency: string; }, UserAccount>({ - url: apiUrl + "/wallet", - method: "PATCH", - useToken: true, - body: { - currency - }, +export async function patchCurrency( + currency: string +): Promise<[UserAccount | null, string?]> { + try { + const patchCurrencyResponse = await makeRequest< + { currency: string }, + UserAccount + >({ + url: apiUrl + "/wallet", + method: "PATCH", + useToken: true, + body: { + currency, + }, }); + + return [patchCurrencyResponse]; + } catch (nativeError) { + const error = parseAxiosError(nativeError); + + return [null, `Не удалось изменить валюту. ${error}`]; + } } diff --git a/src/components/Drawers.tsx b/src/components/Drawers.tsx index 63f7114..e0c4df3 100644 --- a/src/components/Drawers.tsx +++ b/src/components/Drawers.tsx @@ -1,12 +1,25 @@ 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 SectionWrapper from "./SectionWrapper"; import CustomWrapperDrawer from "./CustomWrapperDrawer"; import { NotificationsModal } from "./NotificationsModal"; import { useCart } from "@root/utils/hooks/useCart"; 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 { useTicketStore } from "@root/stores/tickets"; @@ -20,7 +33,8 @@ import { enqueueSnackbar } from "notistack"; import { Link, useNavigate } from "react-router-dom"; export default function Drawers() { - const [openNotificationsModal, setOpenNotificationsModal] = useState(false); + const [openNotificationsModal, setOpenNotificationsModal] = + useState(false); const bellRef = useRef(null); const navigate = useNavigate(); const theme = useTheme(); @@ -33,263 +47,273 @@ export default function Drawers() { const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState(0); 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; - function handlePayClick() { - payCart() - .then((result) => { - setUserAccount(result); - }) - .catch((error) => { - if (isAxiosError(error) && error.response?.status === 402) { - const notEnoughMoneyAmount = parseInt( - (error.response.data.message as string).replace("insufficient funds: ", "") - ); - setNotEnoughMoneyAmount(notEnoughMoneyAmount); - } else { - const message = getMessageFromFetchError(error); - if (message) enqueueSnackbar(message); - } - }); + async function handlePayClick() { + const [payCartResponse, payCartError] = await payCart(); + + if (payCartError) { + const notEnoughMoneyAmount = parseInt( + payCartError.replace("insufficient funds: ", "") + ); + + setNotEnoughMoneyAmount(notEnoughMoneyAmount); + + return enqueueSnackbar(payCartError); + } + + if (payCartResponse) { + setUserAccount(payCartResponse); + } } function handleReplenishWallet() { navigate("/payment", { state: { notEnoughMoneyAmount } }); } - return ( - - setOpenNotificationsModal((isOpened) => !isOpened)} - sx={{ - cursor: "pointer", - borderRadius: "6px", - background: openNotificationsModal - ? theme.palette.purple.main - : theme.palette.background.default, - "& .MuiBadge-badge": { - background: openNotificationsModal - ? theme.palette.background.default - : theme.palette.purple.main, - color: openNotificationsModal - ? theme.palette.purple.main - : theme.palette.background.default, - }, - "& svg > path:first-child": { - fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", - }, - "& svg > path:last-child": { - stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", - }, - "&:hover": { - background: theme.palette.purple.main, - "& .MuiBox-root": { - background: theme.palette.purple.main, - }, - "& .MuiBadge-badge": { - background: theme.palette.background.default, - color: theme.palette.purple.main, - }, - "& svg > path:first-child": { fill: "#FFFFFF" }, - "& svg > path:last-child": { stroke: "#FFFFFF" }, - }, - }} + return ( + + setOpenNotificationsModal((isOpened) => !isOpened)} + sx={{ + cursor: "pointer", + borderRadius: "6px", + background: openNotificationsModal + ? theme.palette.purple.main + : theme.palette.background.default, + "& .MuiBadge-badge": { + background: openNotificationsModal + ? theme.palette.background.default + : theme.palette.purple.main, + color: openNotificationsModal + ? theme.palette.purple.main + : theme.palette.background.default, + }, + "& svg > path:first-child": { + fill: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", + }, + "& svg > path:last-child": { + stroke: openNotificationsModal ? "#FFFFFF" : "#9A9AAF", + }, + "&:hover": { + background: theme.palette.purple.main, + "& .MuiBox-root": { + background: theme.palette.purple.main, + }, + "& .MuiBadge-badge": { + background: theme.palette.background.default, + color: theme.palette.purple.main, + }, + "& svg > path:first-child": { fill: "#FFFFFF" }, + "& svg > path:last-child": { stroke: "#FFFFFF" }, + }, + }} + > + + + + + 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, + }))} + /> + path:nth-child(1)": { fill: "#FFFFFF" }, + "& svg > path:nth-child(2)": { fill: "#FFFFFF" }, + "& svg > path:nth-child(3)": { stroke: "#FFFFFF" }, + }, + }} + > + + + + + + + + - - - + Корзина + + + - 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, - }))} - /> - path:nth-child(1)": { fill: "#FFFFFF" }, - "& svg > path:nth-child(2)": { fill: "#FFFFFF" }, - "& svg > path:nth-child(3)": { stroke: "#FFFFFF" }, - }, - }} + + + {cart.services.map((serviceData) => ( + + ))} + - + + Итоговая цена + + + Текст-заполнитель — это текст, который имеет Текст-заполнитель + — это текст, который имеет Текст-заполнитель — это текст, + который имеет Текст-заполнитель — это текст, который имеет + Текст-заполнитель + + + + - - - - - + {currencyFormatter.format(cart.priceBeforeDiscounts / 100)} + + + {currencyFormatter.format(cart.priceAfterDiscounts / 100)} + + + - - - - - - - ); + Оплатить + + + + + + + + ); } diff --git a/src/components/Navbar/NavbarFull.tsx b/src/components/Navbar/NavbarFull.tsx index 02e636c..785a125 100644 --- a/src/components/Navbar/NavbarFull.tsx +++ b/src/components/Navbar/NavbarFull.tsx @@ -1,103 +1,94 @@ import { Link, useNavigate } from "react-router-dom"; -import { - Box, Container, - Typography, - useTheme -} from "@mui/material"; +import { Box, Container, Typography, useTheme } from "@mui/material"; import Drawers from "../Drawers"; import PenaLogo from "../PenaLogo"; import Menu from "../Menu"; import { logout } from "@root/api/auth"; import { enqueueSnackbar } from "notistack"; 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 { currencyFormatter } from "@root/utils/currencyFormatter"; +import { clearTickets } from "@root/stores/tickets"; + import type { ReactNode } from "react"; - interface Props { - children: ReactNode; + children: ReactNode; } export default function NavbarFull({ children }: Props) { - const theme = useTheme(); - const navigate = useNavigate(); - const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; - const initials = useUserStore(state => state.initials); + const theme = useTheme(); + const navigate = useNavigate(); + const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; + const initials = useUserStore((state) => state.initials); - async function handleLogoutClick() { - try { - await logout(); - clearAuthToken(); - clearUserData(); - clearCustomTariffs(); - navigate("/"); - } catch (error: any) { - const message = getMessageFromFetchError(error, "Не удалось выйти"); - if (message) enqueueSnackbar(message); - } + async function handleLogoutClick() { + const [_, logoutError] = await logout(); + + if (logoutError) { + return enqueueSnackbar(logoutError); } - return ( - - + + + + + + + + + + - - - - - - - - - - Мой баланс - - - {currencyFormatter.format(cash / 100)} - - - {initials} - - - - {children} + Мой баланс + + + {currencyFormatter.format(cash / 100)} + + + + {initials} + + - ); + + {children} + + ); } diff --git a/src/components/Navbar/NavbarPanel.tsx b/src/components/Navbar/NavbarPanel.tsx index fcbb60f..e4926d6 100644 --- a/src/components/Navbar/NavbarPanel.tsx +++ b/src/components/Navbar/NavbarPanel.tsx @@ -1,17 +1,17 @@ import { Link, useNavigate } from "react-router-dom"; import { - Box, - IconButton, - Typography, - useTheme, - useMediaQuery, + Box, + IconButton, + Typography, + useTheme, + useMediaQuery, } from "@mui/material"; import LogoutIcon from "../icons/LogoutIcon"; import Drawers from "../Drawers"; import { logout } from "@root/api/auth"; import { enqueueSnackbar } from "notistack"; 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 { clearTickets } from "@root/stores/tickets"; @@ -20,74 +20,72 @@ import { currencyFormatter } from "@root/utils/currencyFormatter"; import walletIcon from "@root/assets/Icons/wallet_icon.svg"; export const NavbarPanel = () => { - const navigate = useNavigate(); - const theme = useTheme(); - const isTablet = useMediaQuery(theme.breakpoints.down(1000)); - const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; - const initials = useUserStore(state => state.initials); + const navigate = useNavigate(); + const theme = useTheme(); + const isTablet = useMediaQuery(theme.breakpoints.down(1000)); + const cash = useUserStore((state) => state.userAccount?.wallet.cash) ?? 0; + const initials = useUserStore((state) => state.initials); - async function handleLogoutClick() { - try { - await logout(); - clearAuthToken(); - clearUserData(); - clearCustomTariffs(); - clearTickets(); - navigate("/"); - } catch (error: any) { - const message = getMessageFromFetchError(error, "Не удалось выйти"); - if (message) enqueueSnackbar(message); - } + async function handleLogoutClick() { + const [_, logoutError] = await logout(); + + if (logoutError) { + return enqueueSnackbar(logoutError); } - return ( - - - navigate("/wallet")} - > - wallet - - - - Мой баланс - - - {currencyFormatter.format(cash / 100)} - - - {initials} - - - - - ); + clearAuthToken(); + clearUserData(); + clearCustomTariffs(); + clearTickets(); + navigate("/"); + } + + return ( + + + navigate("/wallet")} + > + wallet + + + + Мой баланс + + + {currencyFormatter.format(cash / 100)} + + + + {initials} + + + + + + ); }; diff --git a/src/components/NavbarOld/NavbarFull.tsx b/src/components/NavbarOld/NavbarFull.tsx index 8beef5f..691fc53 100644 --- a/src/components/NavbarOld/NavbarFull.tsx +++ b/src/components/NavbarOld/NavbarFull.tsx @@ -1,6 +1,14 @@ import { useState } from "react"; 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 LogoutIcon from "../icons/LogoutIcon"; import WalletIcon from "../icons/WalletIcon"; @@ -11,13 +19,17 @@ import Menu from "../Menu"; import { logout } from "@root/api/auth"; import { enqueueSnackbar } from "notistack"; 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 { clearCustomTariffs } from "@root/stores/customTariffs"; import { clearTickets } from "@root/stores/tickets"; interface Props { - isLoggedIn: boolean; + isLoggedIn: boolean; } export default function NavbarFull({ isLoggedIn }: Props) { @@ -30,17 +42,17 @@ export default function NavbarFull({ isLoggedIn }: Props) { const [open, setOpen] = useState(false); async function handleLogoutClick() { - try { - await logout(); - clearAuthToken(); - clearUserData(); - clearCustomTariffs(); - clearTickets(); - navigate("/"); - } catch (error: any) { - const message = getMessageFromFetchError(error, "Не удалось выйти"); - if (message) enqueueSnackbar(message); + const [_, logoutError] = await logout(); + + if (logoutError) { + return enqueueSnackbar(logoutError); } + + clearAuthToken(); + clearUserData(); + clearCustomTariffs(); + clearTickets(); + navigate("/"); } return isLoggedIn ? ( @@ -70,7 +82,10 @@ export default function NavbarFull({ isLoggedIn }: Props) { }} > - navigate("/wallet")}> + navigate("/wallet")} + > @@ -134,7 +149,10 @@ export default function NavbarFull({ isLoggedIn }: Props) { > Личный кабинет - setOpen(!open)} sx={{ color: "white", display: !isTablet ? "block" : "none" }} /> + setOpen(!open)} + sx={{ color: "white", display: !isTablet ? "block" : "none" }} + /> ); diff --git a/src/components/TotalPrice.tsx b/src/components/TotalPrice.tsx index 9045e07..d43821c 100644 --- a/src/components/TotalPrice.tsx +++ b/src/components/TotalPrice.tsx @@ -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 { payCart } from "@root/api/cart"; import { setUserAccount } from "@root/stores/user"; @@ -13,29 +20,32 @@ interface Props { priceAfterDiscounts: number; } -export default function TotalPrice({ priceAfterDiscounts, priceBeforeDiscounts }: Props) { +export default function TotalPrice({ + priceAfterDiscounts, + priceBeforeDiscounts, +}: Props) { const theme = useTheme(); const upMd = useMediaQuery(theme.breakpoints.up("md")); const isMobile = useMediaQuery(theme.breakpoints.down(550)); const [notEnoughMoneyAmount, setNotEnoughMoneyAmount] = useState(0); const navigate = useNavigate(); - function handlePayClick() { - payCart() - .then((result) => { - setUserAccount(result); - }) - .catch((error) => { - if (isAxiosError(error) && error.response?.status === 402) { - const notEnoughMoneyAmount = parseInt( - (error.response.data.message as string).replace("insufficient funds: ", "") - ); - setNotEnoughMoneyAmount(notEnoughMoneyAmount); - } else { - const message = getMessageFromFetchError(error); - if (message) enqueueSnackbar(message); - } - }); + async function handlePayClick() { + const [payCartResponse, payCartError] = await payCart(); + + if (payCartError) { + const notEnoughMoneyAmount = parseInt( + payCartError.replace("insufficient funds: ", "") + ); + + setNotEnoughMoneyAmount(notEnoughMoneyAmount); + + return enqueueSnackbar(payCartError); + } + + if (payCartResponse) { + setUserAccount(payCartResponse); + } } function handleReplenishWallet() { @@ -65,8 +75,9 @@ export default function TotalPrice({ priceAfterDiscounts, priceBeforeDiscounts } Итоговая цена - Текст-заполнитель — это текст, который имеет Текст-заполнитель — это текст, который имеет Текст-заполнитель — - это текст, который имеет Текст-заполнитель — это текст, который имеет Текст-заполнитель + Текст-заполнитель — это текст, который имеет Текст-заполнитель — это + текст, который имеет Текст-заполнитель — это текст, который имеет + Текст-заполнитель — это текст, который имеет Текст-заполнитель {notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"} + > + {notEnoughMoneyAmount === 0 ? "Оплатить" : "Пополнить"} + ); diff --git a/src/stores/user.ts b/src/stores/user.ts index 5a783a5..7c2e7c4 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -293,18 +293,29 @@ export const sendUserData = async () => { }; export const addTariffToCart = async (tariffId: string) => { - const result = await patchCart(tariffId); - setCart(result); + const [patchCartResponse, patchCartError] = await patchCart(tariffId); + + if (!patchCartError) { + setCart(patchCartResponse); + } }; export const removeTariffFromCart = async (tariffId: string) => { - const result = await deleteCart(tariffId); - setCart(result); + const [deleteCartResponse, deleteCartError] = await deleteCart(tariffId); + + if (!deleteCartError) { + setCart(deleteCartResponse); + } }; export const changeUserCurrency = async (currency: string) => { - const result = await patchCurrency(currency); - setUserAccount(result); + const [patchCurrencyResponse, patchCurrencyError] = await patchCurrency( + currency + ); + + if (!patchCurrencyError && patchCurrencyResponse) { + setUserAccount(patchCurrencyResponse); + } }; const validators: Record = { diff --git a/src/utils/parse-error.ts b/src/utils/parse-error.ts index 26c5d84..ce56ad8 100644 --- a/src/utils/parse-error.ts +++ b/src/utils/parse-error.ts @@ -1,23 +1,25 @@ import { AxiosError } from "axios"; -const parseAxiosError = (error: AxiosError): string => { +export const parseAxiosError = (nativeError: unknown): string => { + const error = nativeError as AxiosError; + switch (error.status) { case 404: - return "Не найдено"; + return "Не найдено."; case 403: - return "Доступ ограничен"; + return "Доступ ограничен."; case 401: - return "Ошибка авторизации"; + return "Ошибка авторизации."; case 500: - return "Внутренняя ошибка сервера"; + return "Внутренняя ошибка сервера."; case 503: - return "Сервис недоступен"; + return "Сервис недоступен."; default: - return "Неизвестная ошибка сервера"; + return "Неизвестная ошибка сервера."; } };