From f83a5bf1e3984efb165e471c4908d8309050b393 Mon Sep 17 00:00:00 2001 From: Stepan Pilipenko Date: Sat, 8 Nov 2025 21:25:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/add-money-modal.tsx | 88 +++++++++++++++++ .../src/components/delete-account-modal.tsx | 69 +++++++++++++ frontend/src/components/index.ts | 2 + frontend/src/components/product-card.tsx | 2 +- frontend/src/components/product-detail.tsx | 40 ++++---- frontend/src/hocs/with-response.tsx | 18 +--- frontend/src/layout/body.tsx | 5 +- frontend/src/layout/header.tsx | 1 + frontend/src/pages/basket.tsx | 99 ++++++++++++++----- frontend/src/pages/login.tsx | 9 +- frontend/src/pages/logout.tsx | 8 +- frontend/src/pages/register.tsx | 8 +- frontend/src/pages/user.tsx | 68 +++++++++++-- frontend/src/storage/basket-slice.ts | 30 +++++- frontend/src/storage/shop-slice.ts | 16 ++- frontend/src/storage/user-slice.ts | 5 +- 16 files changed, 380 insertions(+), 88 deletions(-) create mode 100644 frontend/src/components/add-money-modal.tsx create mode 100644 frontend/src/components/delete-account-modal.tsx diff --git a/frontend/src/components/add-money-modal.tsx b/frontend/src/components/add-money-modal.tsx new file mode 100644 index 0000000..482ee4a --- /dev/null +++ b/frontend/src/components/add-money-modal.tsx @@ -0,0 +1,88 @@ +import { memo, useState } from 'react' +import { Modal, Box, Typography, TextField, Button, IconButton } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' + +type AddMoneyModalProps = { + open: boolean + onClose: () => void + onAdd: (value: number) => void +} + +export const AddMoneyModal = memo(({ open, onClose, onAdd }: AddMoneyModalProps) => { + const [amount, setAmount] = useState('') + const [error, setError] = useState(false) + + const handleAdd = () => { + const value = parseFloat(amount) + if (value > 0 && !isNaN(value)) { + onAdd(value) + setAmount('') + setError(false) + onClose() + } else { + setError(true) + } + } + + const handleChange: React.ChangeEventHandler = e => { + const val = e.target.value + // Разрешаем только цифры и одну десятичную точку + if (/^\d*\.?\d*$/.test(val)) { + setAmount(val) + setError(false) + } + // Пустое значение разрешено (ошибка будет при попытке отправить) + } + + return ( + + + + Добавить денег + + + + + + + + + + + + + + ) +}) diff --git a/frontend/src/components/delete-account-modal.tsx b/frontend/src/components/delete-account-modal.tsx new file mode 100644 index 0000000..e6d596e --- /dev/null +++ b/frontend/src/components/delete-account-modal.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { Modal, Box, Typography, Button, IconButton } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' + +interface DeleteAccountModalProps { + /** + * Управляет видимостью модального окна + */ + open: boolean + + /** + * Функция, вызываемая при закрытии (отмене) + */ + onClose: () => void + + /** + * Функция, вызываемая при подтверждении удаления + */ + onConfirm: () => void +} + +export const DeleteAccountModal: React.FC = ({ + open, + onClose, + onConfirm, +}) => { + return ( + + + + Удалить аккаунт + + + + + + + Вы уверены, что хотите удалить свой аккаунт? Это действие невозможно отменить. + + + + + + + + + ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index b43ccb3..9f83f9d 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -5,3 +5,5 @@ export * from './product-card' export * from './product-detail' export * from './cart-button' export * from './back-button' +export * from './add-money-modal' +export * from './delete-account-modal' diff --git a/frontend/src/components/product-card.tsx b/frontend/src/components/product-card.tsx index f69c711..fcc10f4 100644 --- a/frontend/src/components/product-card.tsx +++ b/frontend/src/components/product-card.tsx @@ -29,7 +29,7 @@ export const ProductCard = ({ productId }: ProductCardProps) => { } title={name} - subheader={'subheader'} + subheader={'2025 год'} /> { const product = useAppSelector(shop.selector.product(productId)) - const { id, name, count, picture_url, description, cost } = product || { - id: 0, - name: '', - count: 0, - picture_url: '', - description: '', - cost: '', - } - const user_count = useAppSelector(basket.selector.userCount(id)) + const error = useAppSelector(basket.selector.error) + + const user_count = useAppSelector(basket.selector.userCount(productId)) const dispatch = useAppDispatch() const handleCart = useCallback( (num: number) => { if (num > 0) { - dispatch(basket.fetch.addProduct({ product_id: id })) + dispatch(basket.fetch.addProduct({ product_id: product?.id || -1 })) + dispatch(shop.action.changeCount({ productId, count: -1 })) } else { - dispatch(basket.fetch.deleteProduct({ product_id: id })) + dispatch(basket.fetch.deleteProduct({ product_id: product?.id || -1 })) + dispatch(shop.action.changeCount({ productId, count: 1 })) } }, - [dispatch, id] + [dispatch] ) + if (error) { + toast.error(error || 'Не известная ошибка при аутентификации пользователя', { + toastId: 'error-toast', + }) + } return ( @@ -58,28 +60,28 @@ export const ProductDetail = withProtection(({ productId }: ProductDetailProps) - {name[0]} + {product?.name[0]} } - title={name} + title={product?.name} subheader={'2025 год'} /> - {description} + {product?.description} - {`Цена: ${cost} ₽`} + {`Цена: ${product?.cost} ₽`} - {`Доступно: ${count} шт.`} + {`Доступно: ${product?.count} шт.`} ( const error: string = useAppSelector(storage.selector.error) const dispatch = useAppDispatch() - const location = useLocation() - useEffect(() => { if (accessToken) { dispatch(fecthFunc()) @@ -34,18 +31,9 @@ export const withResponse =

( // Если ошибка, то нужно отправить пользователя на странице входа в систему if (error) { - toast.error(error || 'Не известная ошибка при аутентификации пользователя') - return ( - - ) + toast.error(error || 'Не известная ошибка при аутентификации пользователя', { + toastId: 'error-toast', + }) } if (loading) { diff --git a/frontend/src/layout/body.tsx b/frontend/src/layout/body.tsx index 02c2f2b..0582228 100644 --- a/frontend/src/layout/body.tsx +++ b/frontend/src/layout/body.tsx @@ -13,10 +13,11 @@ export const Body = ({ loading }: BodyProps): JSX.Element => { <> {loading ? ( diff --git a/frontend/src/layout/header.tsx b/frontend/src/layout/header.tsx index ff6b9b5..9dd426d 100644 --- a/frontend/src/layout/header.tsx +++ b/frontend/src/layout/header.tsx @@ -17,6 +17,7 @@ import { useAppSelector, user } from '../storage' const pages = [ { name: 'Продукты', to: '/' }, { name: 'Корзина', to: '/basket' }, + { name: 'История', to: '/history' }, ] export const Header = (): JSX.Element => { diff --git a/frontend/src/pages/basket.tsx b/frontend/src/pages/basket.tsx index b33b107..3ec74b1 100644 --- a/frontend/src/pages/basket.tsx +++ b/frontend/src/pages/basket.tsx @@ -1,6 +1,7 @@ -import { Fragment, useCallback } from 'react' +import { Fragment, useCallback, useMemo, useState } from 'react' import { Box, + Button, Divider, IconButton, List, @@ -10,11 +11,14 @@ import { Typography, } from '@mui/material' import { AddCircleOutline, RemoveCircleOutline } from '@mui/icons-material' -import { basket, useAppSelector } from '../storage' -import { BackButton } from '../components' -import { withProtection, withResponse } from '../hocs' +import { basket, useAppDispatch, useAppSelector } from '../storage' +import { BackButton, Spinner } from '../components' +import { withProtection } from '../hocs' import { useNavigate } from 'react-router-dom' import type { BasketType } from '../types' +import { toast } from 'react-toastify' +import { isFulfilledAction, type ResultAction } from '../utils' +import type { ResponseData } from '../network' const MAX_LEN_STR = 20 @@ -22,7 +26,11 @@ const getText = (str?: string): string => { return (str?.length || 0) <= MAX_LEN_STR ? str || '' : str?.substring(0, MAX_LEN_STR) + '...' } -const BasketWithProtection = withProtection(() => { +type LasFetchType = 'addProduct' | 'deleteProduct' | 'buyProducts' | 'none' + +export const BasketPage = withProtection(() => { + const [lasFetch, setLasFetch] = useState('none') + const products = useAppSelector(basket.selector.basket) const navigate = useNavigate() @@ -34,13 +42,27 @@ const BasketWithProtection = withProtection(() => { [products] ) + const sortedProducts = useMemo(() => { + return [...products].sort((product1, product2) => { + if (product1.name < product2.name) return -1 + if (product1.name > product2.name) return 1 + return 0 + }) + }, [products]) + + const dispatch = useAppDispatch() + const handleUpdateQuantity = useCallback( (productId: number, quantity: number) => { - const stock = getProduct(productId)?.user_count || 0 - // TODO: сделать fetch на изменение количества товаров - console.debug({ stock, quantity }) + if (quantity > 0) { + setLasFetch('addProduct') + dispatch(basket.fetch.addProduct({ product_id: productId })) + } else { + setLasFetch('deleteProduct') + dispatch(basket.fetch.deleteProduct({ product_id: productId })) + } }, - [getProduct] + [dispatch] ) const handleProduct = useCallback( @@ -50,6 +72,30 @@ const BasketWithProtection = withProtection(() => { [navigate] ) + const handleBuy = useCallback(async () => { + setLasFetch('buyProducts') + const result: ResultAction = await dispatch(basket.fetch.buyProducts()) + if (isFulfilledAction(result)) { + toast.success('Успешная покупка', { toastId: ' toast-success' }) + } + }, [dispatch]) + + const loading = useAppSelector(basket.selector.loading) + const error = useAppSelector(basket.selector.error) + if (error) { + toast.error(error || 'Не известная ошибка при аутентификации пользователя', { + toastId: 'error-toast', + }) + } + + if (loading && lasFetch === 'buyProducts') { + return ( + + + + ) + } + return (

@@ -60,12 +106,16 @@ const BasketWithProtection = withProtection(() => { - - - {`Заказ №: ${1}`} - - - {products?.map(product => ( + {sortedProducts.length ? ( + + + {`Заказ №: ${1}`} + + + ) : ( + <> + )} + {sortedProducts?.map(product => ( handleProduct(product.id)} @@ -86,18 +136,14 @@ const BasketWithProtection = withProtection(() => { - handleUpdateQuantity(product.id, (product.user_count || 0) + 1) - } + onClick={() => handleUpdateQuantity(product.id, 1)} > - handleUpdateQuantity(product.id, (product.user_count || 0) - 1) - } + onClick={() => handleUpdateQuantity(product.id, -1)} > @@ -106,10 +152,17 @@ const BasketWithProtection = withProtection(() => { + {sortedProducts.length ? ( + + ) : ( + + Корзина пуста + + )}
) }) - -export const BasketPage = withResponse(BasketWithProtection, basket, basket.fetch.basket) diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 82274e9..ade2d10 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -29,11 +29,12 @@ export const LoginPage = (): JSX.Element => { useEffect(() => { if (token && !error) { - toast.success('Вы успешно вошли в систему!') + toast.success('Вы успешно вошли в систему!', { toastId: 'success-toast' }) navigate('/') } if (error) { - toast.error(error) + console.log({ error }) + toast.error(error, { toastId: 'error-toast' }) } }, [token, error, navigate]) @@ -60,7 +61,9 @@ export const LoginPage = (): JSX.Element => { } catch (error: unknown) { // Если произошла ошибка, то выводим уведомление const errorText = (error as ErrorType).error - toast.error(errorText || 'Не известная ошибка при аутентификации пользователя') + toast.error(errorText || 'Не известная ошибка при аутентификации пользователя', { + toastId: 'error-toast', + }) } } diff --git a/frontend/src/pages/logout.tsx b/frontend/src/pages/logout.tsx index 8114a94..c3fd614 100644 --- a/frontend/src/pages/logout.tsx +++ b/frontend/src/pages/logout.tsx @@ -1,5 +1,5 @@ import { useEffect, type JSX } from 'react' -import { useAppDispatch } from '../storage' +import { useAppDispatch, user } from '../storage' import { useNavigate } from 'react-router-dom' export const LogoutPage = (): JSX.Element | null => { @@ -7,11 +7,9 @@ export const LogoutPage = (): JSX.Element | null => { const navigate = useNavigate() useEffect(() => { - // dispatch(userAction.clearUser()) - // dispatch(authAction.clearToken()) - // dispatch(productsAction.clearProducts()) - // dispatch(singleProductAction.clearProduct()) navigate('/') + dispatch(user.fetch.logout()) + dispatch(user.action.clearUser()) }, [dispatch, navigate]) return null diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index a8e8a73..6685ddb 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -31,14 +31,14 @@ export const RegisterPage = (): JSX.Element => { useEffect(() => { if (token && !error) { - toast.success('Вы успешно зарегистрировались!') + toast.success('Вы успешно зарегистрировались!', { toastId: 'success-toast' }) // переходит туда откуда выпали на логин if (state?.from) { navigate(state?.from) } } if (error) { - toast.error(error) + toast.error(error, { toastId: 'error-toast' }) } }, [token, error, navigate, state]) @@ -72,7 +72,9 @@ export const RegisterPage = (): JSX.Element => { } catch (error: unknown) { // Если произошла ошибка, то выводим уведомление const errorText = (error as ErrorType).error - toast.error(errorText || 'Не известная ошибка при аутентификации пользователя') + toast.error(errorText || 'Не известная ошибка при аутентификации пользователя', { + toastId: 'error-toast', + }) } } diff --git a/frontend/src/pages/user.tsx b/frontend/src/pages/user.tsx index 2fb9b2b..97ea648 100644 --- a/frontend/src/pages/user.tsx +++ b/frontend/src/pages/user.tsx @@ -1,18 +1,47 @@ -import { Avatar, Button, Grid, Stack } from '@mui/material' +import { Avatar, Button, Grid, Stack, Typography } from '@mui/material' import { Container } from '@mui/system' import { withProtection, withResponse } from '../hocs' -import { BackButton } from '../components' +import { AddMoneyModal, BackButton, DeleteAccountModal } from '../components' import { user, useAppDispatch, useAppSelector } from '../storage' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' const UserWithProtection = withProtection(() => { const userData = useAppSelector(user.selector.user) const userAvatar = '' + const [openAddMoney, setOpenAddMoney] = useState(false) + const [openDeleteAccout, setOpenDeleteAccout] = useState(false) + const dispatch = useAppDispatch() + const navigate = useNavigate() + + const handleLogout = useCallback(() => { + navigate('/logout') + }, []) + + const handleAddMoney = useCallback((value: number) => { + dispatch(user.fetch.addMoney({ money: String(value) })) + }, []) + + const handleAddMoneyOpen = useCallback(() => { + setOpenAddMoney(true) + }, []) - const handleClick = useCallback(() => { - dispatch(user.fetch.logout()) + const handleAddMoneyClose = useCallback(() => { + setOpenAddMoney(false) + }, []) + + const handleDeleteAccout = useCallback(() => { + dispatch(user.fetch.unregister()) + }, []) + + const handleDeleteAccoutOpen = useCallback(() => { + setOpenDeleteAccout(true) + }, []) + + const handleDeleteAccoutClose = useCallback(() => { + setOpenDeleteAccout(false) }, []) return ( @@ -25,16 +54,35 @@ const UserWithProtection = withProtection(() => { src={userAvatar ? userAvatar : '/static/images/avatar/1.jpg'} sx={{ width: 150, height: 150 }} /> -

{userData?.nickname}

-

{userData?.email}

- + + {`Ник: ${userData?.nickname}`} + + + {`Email: ${userData?.email}`} + + + {`Деньги: ${userData?.money} ₽`} + - + + - +
diff --git a/frontend/src/storage/basket-slice.ts b/frontend/src/storage/basket-slice.ts index 2b72564..acfe8b1 100644 --- a/frontend/src/storage/basket-slice.ts +++ b/frontend/src/storage/basket-slice.ts @@ -39,11 +39,15 @@ const basketSlice = createSlice({ state.loading = false state.basket = action.payload.success as BasketType[] }) - .addMatcher(isActionPending('/basket/'), state => { + .addCase(fetchBuyProducts.fulfilled, (state, action) => { + state.loading = false + state.basket = action.payload.success as BasketType[] + }) + .addMatcher(isActionPending('basket'), state => { state.loading = true state.error = undefined }) - .addMatcher(isActionRejected('/basket/'), (state, action) => { + .addMatcher(isActionRejected('basket'), (state, action) => { state.loading = false if (action.payload) { state.error = (action.payload as ResponseData).error @@ -112,12 +116,32 @@ const fetchDeleteProduct = createAppAsyncThunk( } ) +const fetchBuyProducts = createAppAsyncThunk( + `${basketSlice.name}/fetchBuyProducts`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/basket/', { + method: 'POST', + body: JSON.stringify({ buy_products: {} }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + class Basket { selector = { ...basketSlice.selectors, userCount: selectUserCount } action = basketSlice.actions reducer = basketSlice.reducer name = basketSlice.name - fetch = { basket: fetchBasket, addProduct: fetchAddProduct, deleteProduct: fetchDeleteProduct } + fetch = { + basket: fetchBasket, + addProduct: fetchAddProduct, + deleteProduct: fetchDeleteProduct, + buyProducts: fetchBuyProducts, + } } export const basket = new Basket() diff --git a/frontend/src/storage/shop-slice.ts b/frontend/src/storage/shop-slice.ts index 843a704..4aca8c5 100644 --- a/frontend/src/storage/shop-slice.ts +++ b/frontend/src/storage/shop-slice.ts @@ -1,4 +1,4 @@ -import { createSlice, createSelector } from '@reduxjs/toolkit' +import { createSlice, createSelector, type PayloadAction } from '@reduxjs/toolkit' import type { ProductType } from '../types' import type { ResponseData } from '../network' import { createAppAsyncThunk } from './hooks' @@ -14,10 +14,22 @@ const initialState: ShopState = { error: undefined, } +type ChangeCountProps = { + productId: number + count: number +} + export const shopSlice = createSlice({ name: 'shop', initialState, - reducers: {}, + reducers: { + changeCount: (state, action: PayloadAction) => { + const product = state.products.find(product => product.id === action.payload.productId) + if (product) { + product.count += action.payload.count + } + }, + }, extraReducers: builder => { builder .addCase(fetchProducts.pending, state => { diff --git a/frontend/src/storage/user-slice.ts b/frontend/src/storage/user-slice.ts index 6a6298d..663aa4e 100644 --- a/frontend/src/storage/user-slice.ts +++ b/frontend/src/storage/user-slice.ts @@ -40,6 +40,7 @@ const userSlice = createSlice({ state.user = action.payload }, clearUser() { + token.save('') return initialState }, }, @@ -81,11 +82,11 @@ const userSlice = createSlice({ state.error = undefined token.save(state.user.token) }) - .addMatcher(isActionPending('/user/'), state => { + .addMatcher(isActionPending('user'), state => { state.loading = true state.error = undefined }) - .addMatcher(isActionRejected('/user/'), (state, action) => { + .addMatcher(isActionRejected('user'), (state, action) => { state.loading = false if (action.payload) { state.error = (action.payload as ResponseData).error