без истории

main
Stepan Pilipenko 1 month ago
parent e854b5351a
commit f83a5bf1e3

@ -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<HTMLInputElement | HTMLTextAreaElement> = e => {
const val = e.target.value
// Разрешаем только цифры и одну десятичную точку
if (/^\d*\.?\d*$/.test(val)) {
setAmount(val)
setError(false)
}
// Пустое значение разрешено (ошибка будет при попытке отправить)
}
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 24,
p: 4,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Добавить денег</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<TextField
fullWidth
label="Сумма"
variant="outlined"
value={amount}
onChange={handleChange}
error={error}
helperText={error ? 'Введите корректную сумму' : ''}
placeholder="0.00"
type="text" // не используем type="number", чтобы избежать стрелок и локализационных проблем
sx={{ mb: 3 }}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose}>Отмена</Button>
<Button variant="contained" onClick={handleAdd} disabled={!amount.trim()}>
Добавить
</Button>
</Box>
</Box>
</Modal>
)
})

@ -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<DeleteAccountModalProps> = ({
open,
onClose,
onConfirm,
}) => {
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
borderRadius: 2,
boxShadow: 24,
p: 4,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
}}
>
<Typography variant="h6">Удалить аккаунт</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Вы уверены, что хотите удалить свой аккаунт? Это действие невозможно отменить.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose}>Отмена</Button>
<Button variant="contained" color="error" onClick={onConfirm}>
Удалить
</Button>
</Box>
</Box>
</Modal>
)
}

@ -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'

@ -29,7 +29,7 @@ export const ProductCard = ({ productId }: ProductCardProps) => {
</Avatar>
}
title={name}
subheader={'subheader'}
subheader={'2025 год'}
/>
<Link to={`/product/${id}`}>
<CardMedia

@ -12,6 +12,7 @@ import { useCallback } from 'react'
import { CartButton } from './cart-button'
import { useAppSelector, shop, useAppDispatch, basket } from '../storage'
import { withProtection } from '../hocs'
import { toast } from 'react-toastify'
export type ProductDetailProps = {
productId: number
@ -19,28 +20,29 @@ export type ProductDetailProps = {
export const ProductDetail = withProtection(({ productId }: ProductDetailProps) => {
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 (
<Grid container direction="column" justifyContent="center" alignItems="center">
@ -58,28 +60,28 @@ export const ProductDetail = withProtection(({ productId }: ProductDetailProps)
<CardHeader
avatar={
<Avatar sx={{ bgcolor: 'info.main' }} aria-label="recipe">
{name[0]}
{product?.name[0]}
</Avatar>
}
title={name}
title={product?.name}
subheader={'2025 год'}
/>
<CardMedia
component="img"
height="300"
sx={{ objectPosition: 'top', objectFit: 'contain' }}
image={picture_url}
alt={name}
image={product?.picture_url}
alt={product?.name}
/>
<CardContent>
<Typography variant="body2" color="text.secondary">
{description}
{product?.description}
</Typography>
<Typography noWrap={true} variant="subtitle1" color="success.main">
{`Цена: ${cost}`}
{`Цена: ${product?.cost}`}
</Typography>
<Typography noWrap={true} variant="subtitle1" color="text.primary">
{`Доступно: ${count} шт.`}
{`Доступно: ${product?.count} шт.`}
</Typography>
</CardContent>
<CardActions

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, type ComponentType, type FC } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { user, useAppDispatch, useAppSelector } from '../storage'
import { toast } from 'react-toastify'
import { Box } from '@mui/material'
@ -24,8 +23,6 @@ export const withResponse = <P extends object, T extends StorageType>(
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 = <P extends object, T extends StorageType>(
// Если ошибка, то нужно отправить пользователя на странице входа в систему
if (error) {
toast.error(error || 'Не известная ошибка при аутентификации пользователя')
return (
<Navigate
to="/login"
// при этом мы передаем состояние, в котором указываем, какую
// страницу хотел посетить пользователь. И если он в дальнейшем
// войдет в систему, то мы его автоматически перебросим на желаемую страницу
state={{
from: location.pathname,
}}
/>
)
toast.error(error || 'Не известная ошибка при аутентификации пользователя', {
toastId: 'error-toast',
})
}
if (loading) {

@ -13,10 +13,11 @@ export const Body = ({ loading }: BodyProps): JSX.Element => {
<>
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
autoClose={2000}
hideProgressBar={true}
pauseOnHover
theme="colored"
limit={1}
/>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">

@ -17,6 +17,7 @@ import { useAppSelector, user } from '../storage'
const pages = [
{ name: 'Продукты', to: '/' },
{ name: 'Корзина', to: '/basket' },
{ name: 'История', to: '/history' },
]
export const Header = (): JSX.Element => {

@ -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<LasFetchType>('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<ResponseData> = 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 (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<Spinner />
</Box>
)
}
return (
<div>
<BackButton text="< Главная" path="/" />
@ -60,12 +106,16 @@ const BasketWithProtection = withProtection(() => {
</Stack>
<List component="nav" aria-label="cart items">
<Fragment>
{sortedProducts.length ? (
<Stack direction="row" alignItems="center" spacing={2} sx={{ pb: 2 }}>
<Typography variant="h6" gutterBottom>
{`Заказ №: ${1}`}
</Typography>
</Stack>
{products?.map(product => (
) : (
<></>
)}
{sortedProducts?.map(product => (
<ListItem key={product.id}>
<ListItemText
onClick={() => handleProduct(product.id)}
@ -86,18 +136,14 @@ const BasketWithProtection = withProtection(() => {
<IconButton
edge="start"
aria-label="add"
onClick={() =>
handleUpdateQuantity(product.id, (product.user_count || 0) + 1)
}
onClick={() => handleUpdateQuantity(product.id, 1)}
>
<AddCircleOutline />
</IconButton>
<IconButton
edge="start"
aria-label="remove"
onClick={() =>
handleUpdateQuantity(product.id, (product.user_count || 0) - 1)
}
onClick={() => handleUpdateQuantity(product.id, -1)}
>
<RemoveCircleOutline />
</IconButton>
@ -106,10 +152,17 @@ const BasketWithProtection = withProtection(() => {
<Box sx={{ pb: 3 }}>
<Divider />
</Box>
{sortedProducts.length ? (
<Button color="success" variant="contained" onClick={handleBuy}>
{'КУПИТЬ'}
</Button>
) : (
<Typography variant="button" sx={{ fontSize: 16, pt: 2, pb: 2 }}>
Корзина пуста
</Typography>
)}
</Fragment>
</List>
</div>
)
})
export const BasketPage = withResponse(BasketWithProtection, basket, basket.fetch.basket)

@ -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',
})
}
}

@ -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

@ -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',
})
}
}

@ -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 handleAddMoneyClose = useCallback(() => {
setOpenAddMoney(false)
}, [])
const handleClick = useCallback(() => {
dispatch(user.fetch.logout())
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 }}
/>
<p>{userData?.nickname}</p>
<p>{userData?.email}</p>
<Typography noWrap={true} variant="h6" color="text.secondary">
{`Ник: ${userData?.nickname}`}
</Typography>
<Typography noWrap={true} variant="h6" color="text.secondary">
{`Email: ${userData?.email}`}
</Typography>
<Typography noWrap={true} variant="h6" color="success">
{`Деньги: ${userData?.money}`}
</Typography>
<Stack>
<Button variant="outlined" onClick={handleClick}>
<Button variant="outlined" color="success" onClick={handleAddMoneyOpen}>
Добавить денег
</Button>
<AddMoneyModal
open={openAddMoney}
onClose={handleAddMoneyClose}
onAdd={handleAddMoney}
/>
<Button variant="outlined" onClick={handleLogout}>
Выйти
</Button>
<Button variant="outlined" color="error">
<Button variant="outlined" color="error" onClick={handleDeleteAccoutOpen}>
Удалить аккаунт
</Button>
<DeleteAccountModal
open={openDeleteAccout}
onClose={handleDeleteAccoutClose}
onConfirm={handleDeleteAccout}
/>
</Stack>
</Grid>
</Grid>

@ -39,11 +39,15 @@ const basketSlice = createSlice({
state.loading = false
state.basket = action.payload.success as BasketType[]
})
.addMatcher<PendingAction>(isActionPending('/basket/'), state => {
.addCase(fetchBuyProducts.fulfilled, (state, action) => {
state.loading = false
state.basket = action.payload.success as BasketType[]
})
.addMatcher<PendingAction>(isActionPending('basket'), state => {
state.loading = true
state.error = undefined
})
.addMatcher<RejectedAction>(isActionRejected('/basket/'), (state, action) => {
.addMatcher<RejectedAction>(isActionRejected('basket'), (state, action) => {
state.loading = false
if (action.payload) {
state.error = (action.payload as ResponseData).error
@ -112,12 +116,32 @@ const fetchDeleteProduct = createAppAsyncThunk<ResponseData, ProductProps>(
}
)
const fetchBuyProducts = createAppAsyncThunk<ResponseData>(
`${basketSlice.name}/fetchBuyProducts`,
async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try {
const data = await networkApi.request<ResponseData>('/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()

@ -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<ChangeCountProps>) => {
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 => {

@ -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<PendingAction>(isActionPending('/user/'), state => {
.addMatcher<PendingAction>(isActionPending('user'), state => {
state.loading = true
state.error = undefined
})
.addMatcher<RejectedAction>(isActionRejected('/user/'), (state, action) => {
.addMatcher<RejectedAction>(isActionRejected('user'), (state, action) => {
state.loading = false
if (action.payload) {
state.error = (action.payload as ResponseData).error

Loading…
Cancel
Save