diff --git a/backend/server/urls.py b/backend/server/urls.py index 8970758..df42804 100644 --- a/backend/server/urls.py +++ b/backend/server/urls.py @@ -4,10 +4,6 @@ from django.urls import path from .views import ( shop, user, - login, - logout, - register, - unregister, basket, history, ) @@ -18,10 +14,4 @@ urlpatterns = [ path("user/", user, name="user"), path("basket/", basket, name="basket"), path("history/", history, name="history"), - - # вспомогательные - path("login/", login, name="login"), - path("logout/", logout, name="logout"), - path("register/", register, name="register"), - path("unregister/", unregister, name="unregister"), ] diff --git a/backend/server/views.py b/backend/server/views.py index cde3196..cdb9845 100644 --- a/backend/server/views.py +++ b/backend/server/views.py @@ -27,20 +27,20 @@ async def user(request): if request.body: body: dict = json.loads(request.body) - if body["register"]: + if "register" in body.keys(): token = api.registration(body["register"]["nickname"], body["register"]["password"], body["register"]["email"]) - elif body["login"]: + elif "login" in body.keys(): token = api.login(body["login"]["email"], body["login"]["password"]) - elif body["unregister"]: + elif "unregister" in body.keys(): token = format_token(request.headers.get("Authorization")) api.unregister(token) - elif body["logout"]: + elif "logout" in body.keys(): token = format_token(request.headers.get("Authorization")) api.logout(token) - elif body["add_money"]: + elif "add_money" in body.keys(): token = format_token(request.headers.get("Authorization")) api.add_money(token, body["add_money"]["money"]) else: @@ -62,13 +62,13 @@ async def basket(request): if request.body: body: dict = json.loads(request.body) - if body["add_product"]: + if "add_product" in body.keys(): api.add_product_to_basket(token, body["add_product"]["product_id"]) - elif body["delete_product"]: + elif "delete_product" in body.keys(): api.delete_product_from_basket(token, body["delete_product"]["product_id"]) - elif body["clear"]: + elif "clear" in body.keys(): api.clear_basket(token) - elif body["buy_products"]: + elif "buy_products" in body.keys(): api.buy_products(token) products_id = api.get_products_id(token, "basket") @@ -88,96 +88,3 @@ async def history(request): return JsonResponse({"success": histories}, status=200) except Exception as error: return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def login(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = api.login(body["email"], body["password"]) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def logout(request): - try: - token = None - if request.method == 'POST': - token = format_token(request.headers.get("Authorization")) - api.logout(token) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def register(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = api.registration(body["nickname"], body["password"], body["email"]) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def unregister(request): - try: - token = None - if request.method == 'POST': - token = format_token(request.headers.get("Authorization")) - api.unregister(token) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def add_product_to_basket(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = format_token(request.headers.get("Authorization")) - api.add_product_to_basket(token, body["product_id"]) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def delete_product_from_basket(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = format_token(request.headers.get("Authorization")) - api.delete_product_from_basket(token, body["product_id"]) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def buy_products(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = format_token(request.headers.get("Authorization")) - api.buy_products(token) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - -@csrf_exempt -async def clear_basket(request): - try: - token = None - if request.method == 'POST': - body: dict = json.loads(request.body) - token = format_token(request.headers.get("Authorization")) - api.clear_basket(token) - return JsonResponse({"success": token}, status=200) - except Exception as error: - return JsonResponse({"error": format(error)}, status=500) - diff --git a/backend/tests/api_test.py b/backend/tests/api_test.py index 8e5686e..6d4fddd 100644 --- a/backend/tests/api_test.py +++ b/backend/tests/api_test.py @@ -1,6 +1,6 @@ import unittest import psycopg2 -from backend.api import ( +from api import ( registration, add_product_to_basket, buy_products, @@ -9,10 +9,12 @@ from backend.api import ( unregister, get_products, get_histories_with_products, + get_products_id, + get_products_by_id, add_money # Импортируем напрямую ) -from backend.api import add_product_to_shop, delete_product_from_shop -from backend.consts import * +from api import add_product_to_shop, delete_product_from_shop +from consts import * class TestFullUserFlow(unittest.TestCase): @@ -61,6 +63,9 @@ class TestFullUserFlow(unittest.TestCase): add_product_to_basket(self.token, self.product_ids[0]) # ещё раз add_product_to_basket(self.token, self.product_ids[1]) print("✅ Товары добавлены в корзину") + products_id = get_products_id(self.token, "basket") + basket1 = get_products_by_id(products_id) + print(basket1) def test_04_check_shop_state_after_add_to_basket(self): """4. Проверка, что count и reserved изменились корректно""" diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index d795b5c..35e3a44 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -15,6 +15,7 @@ import { RegisterPage, ShopPage, UserPage, + BasketPage, } from './pages' const routers = createRoutesFromElements( @@ -27,6 +28,7 @@ const routers = createRoutesFromElements( errorElement={} /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/product-detail.tsx b/frontend/src/components/product-detail.tsx index a2ed9c3..bd6041c 100644 --- a/frontend/src/components/product-detail.tsx +++ b/frontend/src/components/product-detail.tsx @@ -10,7 +10,7 @@ import { } from '@mui/material' import { useCallback } from 'react' import { CartButton } from './cart-button' -import { useAppSelector, shop } from '../storage' +import { useAppSelector, shop, useAppDispatch, basket } from '../storage' import { withProtection } from '../hocs' export type ProductDetailProps = { @@ -19,19 +19,28 @@ export type ProductDetailProps = { export const ProductDetail = withProtection(({ productId }: ProductDetailProps) => { const product = useAppSelector(shop.selector.product(productId)) - const { name, count, picture_url, description, cost } = product || { + 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 quantity = 5 + const dispatch = useAppDispatch() - const handleCart = useCallback((num: number) => { - console.debug({ num }) - }, []) + const handleCart = useCallback( + (num: number) => { + if (num > 0) { + dispatch(basket.fetch.addProduct({ product_id: id })) + } else { + dispatch(basket.fetch.deleteProduct({ product_id: id })) + } + }, + [dispatch, id] + ) return ( @@ -77,7 +86,7 @@ export const ProductDetail = withProtection(({ productId }: ProductDetailProps) sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }} disableSpacing > - + diff --git a/frontend/src/hocs/with-protection.tsx b/frontend/src/hocs/with-protection.tsx index ade9571..7d25064 100644 --- a/frontend/src/hocs/with-protection.tsx +++ b/frontend/src/hocs/with-protection.tsx @@ -1,11 +1,11 @@ import { type ComponentType, type FC } from 'react' import { Navigate, useLocation } from 'react-router-dom' -import { auth, useAppSelector } from '../storage' +import { user, useAppSelector } from '../storage' export const withProtection =

(WrappedComponent: ComponentType

) => { const ReturnedComponent: FC

= props => { // Достаем accessToken из redux'a - const accessToken = useAppSelector(auth.selector.accessToken) + const accessToken = useAppSelector(user.selector.accessToken) // Объект location на понадобиться для задания состояния при redirect'e const location = useLocation() diff --git a/frontend/src/hocs/with-response.tsx b/frontend/src/hocs/with-response.tsx index 53dc280..6f46afd 100644 --- a/frontend/src/hocs/with-response.tsx +++ b/frontend/src/hocs/with-response.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, type ComponentType, type FC } from 'react' import { Navigate, useLocation } from 'react-router-dom' -import { auth, useAppDispatch, useAppSelector } from '../storage' +import { user, useAppDispatch, useAppSelector } from '../storage' import { toast } from 'react-toastify' import { Box } from '@mui/material' import { Spinner } from '../components' @@ -19,7 +19,7 @@ export const withResponse =

( fecthFunc: () => any ) => { const ReturnedComponent: FC

= props => { - const accessToken = useAppSelector(auth.selector.accessToken) + const accessToken = useAppSelector(user.selector.accessToken) const loading: boolean = useAppSelector(storage.selector.loading) const error: string = useAppSelector(storage.selector.error) const dispatch = useAppDispatch() @@ -30,7 +30,7 @@ export const withResponse =

( if (accessToken) { dispatch(fecthFunc()) } - }, [accessToken]) + }, [accessToken, dispatch]) // Если ошибка, то нужно отправить пользователя на странице входа в систему if (error) { diff --git a/frontend/src/pages/basket.tsx b/frontend/src/pages/basket.tsx new file mode 100644 index 0000000..b33b107 --- /dev/null +++ b/frontend/src/pages/basket.tsx @@ -0,0 +1,115 @@ +import { Fragment, useCallback } from 'react' +import { + Box, + Divider, + IconButton, + List, + ListItem, + ListItemText, + Stack, + 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 { useNavigate } from 'react-router-dom' +import type { BasketType } from '../types' + +const MAX_LEN_STR = 20 + +const getText = (str?: string): string => { + return (str?.length || 0) <= MAX_LEN_STR ? str || '' : str?.substring(0, MAX_LEN_STR) + '...' +} + +const BasketWithProtection = withProtection(() => { + const products = useAppSelector(basket.selector.basket) + + const navigate = useNavigate() + + const getProduct = useCallback( + (productId: number): BasketType | null => { + return products?.find(product => product.id === productId) || null + }, + [products] + ) + + const handleUpdateQuantity = useCallback( + (productId: number, quantity: number) => { + const stock = getProduct(productId)?.user_count || 0 + // TODO: сделать fetch на изменение количества товаров + console.debug({ stock, quantity }) + }, + [getProduct] + ) + + const handleProduct = useCallback( + (productId: number) => { + navigate(`/product/${productId}`) + }, + [navigate] + ) + + return ( +

+ + + + Продуктовая корзина + + + + + + + {`Заказ №: ${1}`} + + + {products?.map(product => ( + + handleProduct(product.id)} + primary={`Название: ${getText(getProduct(product.id)?.name)}`} + /> + handleProduct(product.id)} + primary={`Цена: ${Number(product.cost) * product.user_count} ₽`} + /> + handleProduct(product.id)} + primary={`На складе: ${getProduct(product.id)?.count} `} + /> + handleProduct(product.id)} + primary={`${product.user_count} шт.`} + /> + + handleUpdateQuantity(product.id, (product.user_count || 0) + 1) + } + > + + + + handleUpdateQuantity(product.id, (product.user_count || 0) - 1) + } + > + + + + ))} + + + + + +
+ ) +}) + +export const BasketPage = withResponse(BasketWithProtection, basket, basket.fetch.basket) diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 43aed8c..788cad3 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -6,3 +6,4 @@ export * from './not-found' export * from './product' export * from './register' export * from './user' +export * from './basket' diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 822b99b..82274e9 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -6,7 +6,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import { toast } from 'react-toastify' import { Link as RouterLink, useNavigate } from 'react-router-dom' import * as yup from 'yup' -import { auth, useAppDispatch, useAppSelector } from '../storage' +import { user, useAppDispatch, useAppSelector } from '../storage' import type { ErrorType } from '../types' export interface LoginFormValues { @@ -22,8 +22,8 @@ const loginFormSchema = yup.object({ export const LoginPage = (): JSX.Element => { const dispatch = useAppDispatch() - const token = useAppSelector(auth.selector.accessToken) - const error = useAppSelector(auth.selector.error) + const token = useAppSelector(user.selector.accessToken) + const error = useAppSelector(user.selector.error) const navigate = useNavigate() @@ -56,7 +56,7 @@ export const LoginPage = (): JSX.Element => { const submitHandler: SubmitHandler = async values => { try { - dispatch(auth.fetch.login({ email: values.email, password: values.password })) + dispatch(user.fetch.login({ email: values.email, password: values.password })) } catch (error: unknown) { // Если произошла ошибка, то выводим уведомление const errorText = (error as ErrorType).error diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index 9f606b5..a8e8a73 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -7,7 +7,7 @@ import { toast } from 'react-toastify' import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom' import * as yup from 'yup' import type { ErrorType } from '../types' -import { auth, useAppDispatch, useAppSelector } from '../storage' +import { user, useAppDispatch, useAppSelector } from '../storage' export interface RegistrFormValues { email: string @@ -23,8 +23,8 @@ const registrFormSchema = yup.object({ export const RegisterPage = (): JSX.Element => { const dispatch = useAppDispatch() - const token = useAppSelector(auth.selector.accessToken) - const error = useAppSelector(auth.selector.error) + const token = useAppSelector(user.selector.accessToken) + const error = useAppSelector(user.selector.error) const { state } = useLocation() const navigate = useNavigate() @@ -63,7 +63,7 @@ export const RegisterPage = (): JSX.Element => { const submitHandler: SubmitHandler = async values => { try { dispatch( - auth.fetch.register({ + user.fetch.register({ email: values.email, nickname: values.nickname, password: values.password, diff --git a/frontend/src/pages/user.tsx b/frontend/src/pages/user.tsx index 40db234..2fb9b2b 100644 --- a/frontend/src/pages/user.tsx +++ b/frontend/src/pages/user.tsx @@ -2,7 +2,7 @@ import { Avatar, Button, Grid, Stack } from '@mui/material' import { Container } from '@mui/system' import { withProtection, withResponse } from '../hocs' import { BackButton } from '../components' -import { auth, useAppDispatch, useAppSelector, user } from '../storage' +import { user, useAppDispatch, useAppSelector } from '../storage' import { useCallback } from 'react' const UserWithProtection = withProtection(() => { @@ -12,7 +12,7 @@ const UserWithProtection = withProtection(() => { const dispatch = useAppDispatch() const handleClick = useCallback(() => { - dispatch(auth.fetch.logout()) + dispatch(user.fetch.logout()) }, []) return ( diff --git a/frontend/src/storage/auth-slice.ts b/frontend/src/storage/auth-slice.ts deleted file mode 100644 index 82ef372..0000000 --- a/frontend/src/storage/auth-slice.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit' -import type { PayloadAction } from '@reduxjs/toolkit' -import { createAppAsyncThunk } from './hooks' -import { type ResponseData } from '../network' -import { token } from '../utils' - -interface AuthState { - accessToken: string - loading: boolean - error?: string -} - -const initialState: AuthState = { - accessToken: token.load(), - loading: false, - error: undefined, -} - -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: { - setAccessToken(state, action: PayloadAction) { - state.accessToken = action.payload.accessToken - token.save(state.accessToken) - }, - clearToken() { - token.save('') - return initialState - }, - }, - extraReducers: builder => { - builder - .addCase(fetchLogin.pending, state => { - state.loading = true - state.error = undefined - }) - .addCase(fetchLogin.fulfilled, (state, action) => { - state.accessToken = String(action.payload.success) || '' - state.loading = false - state.error = undefined - token.save(state.accessToken) - }) - .addCase(fetchLogin.rejected, (state, action) => { - state.loading = false - if (action.payload) { - state.error = (action.payload as ResponseData).error - } else { - state.error = action.error.message - } - }) - .addCase(fetchLogout.pending, state => { - state.loading = true - state.error = undefined - }) - .addCase(fetchLogout.fulfilled, state => { - state.accessToken = '' - state.loading = false - state.error = undefined - token.save('') - }) - .addCase(fetchLogout.rejected, (state, action) => { - state.loading = false - if (action.payload) { - state.error = (action.payload as ResponseData).error - } else { - state.error = action.error.message - } - }) - .addCase(fetchRegister.pending, state => { - state.loading = true - state.error = undefined - }) - .addCase(fetchRegister.fulfilled, (state, action) => { - state.accessToken = String(action.payload.success) || '' - state.loading = false - state.error = undefined - token.save(state.accessToken) - }) - .addCase(fetchRegister.rejected, (state, action) => { - state.loading = false - if (action.payload) { - state.error = (action.payload as ResponseData).error - } else { - state.error = action.error.message - } - }) - }, - selectors: { - accessToken: (state: AuthState) => state.accessToken, - loading: (state: AuthState) => state.loading, - error: (state: AuthState) => state.error, - }, -}) - -type RegisterProps = { - email: string - nickname: string - password: string -} - -const fetchRegister = createAppAsyncThunk( - `${authSlice.name}/fetchRegister`, - async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { - try { - const data = await networkApi.request('/register/', { - method: 'POST', - body: JSON.stringify(props), - }) - return fulfillWithValue(data) - } catch (error) { - return rejectWithValue(error) - } - } -) - -type LoginProps = { - email: string - password: string -} - -const fetchLogin = createAppAsyncThunk( - `${authSlice.name}/fetchLogin`, - async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { - try { - const data = await networkApi.request('/login/', { - method: 'POST', - body: JSON.stringify(props), - }) - return fulfillWithValue(data) - } catch (error) { - return rejectWithValue(error) - } - } -) - -const fetchLogout = createAppAsyncThunk( - `${authSlice.name}/fetchLogout`, - async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { - try { - const data = await networkApi.request('/logout/') - return fulfillWithValue(data) - } catch (error) { - return rejectWithValue(error) - } - } -) - -class Auth { - selector = { ...authSlice.selectors } - action = authSlice.actions - reducer = authSlice.reducer - name = authSlice.name - fetch = { login: fetchLogin, logout: fetchLogout, register: fetchRegister } -} - -export const auth = new Auth() diff --git a/frontend/src/storage/basket-slice.ts b/frontend/src/storage/basket-slice.ts new file mode 100644 index 0000000..2b72564 --- /dev/null +++ b/frontend/src/storage/basket-slice.ts @@ -0,0 +1,123 @@ +import { createSelector, createSlice } from '@reduxjs/toolkit' +import { createAppAsyncThunk } from './hooks' +import type { ResponseData } from '../network' +import type { BasketType } from '../types' +import { + isActionPending, + isActionRejected, + type PendingAction, + type RejectedAction, +} from '../utils' + +type BasketState = { + basket: BasketType[] + loading: boolean + error?: string +} + +const initialState: BasketState = { + basket: [], + loading: false, + error: undefined, +} + +const basketSlice = createSlice({ + name: 'basket', + initialState, + reducers: {}, + extraReducers: builder => { + builder + .addCase(fetchBasket.fulfilled, (state, action) => { + state.basket = action.payload.success as BasketType[] + state.loading = false + }) + .addCase(fetchAddProduct.fulfilled, (state, action) => { + state.loading = false + state.basket = action.payload.success as BasketType[] + }) + .addCase(fetchDeleteProduct.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) => { + state.loading = false + if (action.payload) { + state.error = (action.payload as ResponseData).error + } else { + state.error = action.error.message + } + }) + }, + selectors: { + basket: (state: BasketState) => state.basket, + loading: (state: BasketState) => state.loading, + error: (state: BasketState) => state.error, + }, +}) + +const selectUserCount = (productId: number) => + createSelector([basketSlice.selectors.basket], products => { + return products?.find(product => product.id === productId)?.user_count + }) + +const fetchBasket = createAppAsyncThunk( + `${basketSlice.name}/fetchBasket`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/basket/', { + method: 'POST', + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +type ProductProps = { + product_id: number +} + +const fetchAddProduct = createAppAsyncThunk( + `${basketSlice.name}/fetchAddProduct`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/basket/', { + method: 'POST', + body: JSON.stringify({ add_product: props }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +const fetchDeleteProduct = createAppAsyncThunk( + `${basketSlice.name}/fetchDeleteProduct`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/basket/', { + method: 'POST', + body: JSON.stringify({ delete_product: props }), + }) + 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 } +} + +export const basket = new Basket() diff --git a/frontend/src/storage/index.ts b/frontend/src/storage/index.ts index 0a07543..2bd4d9d 100644 --- a/frontend/src/storage/index.ts +++ b/frontend/src/storage/index.ts @@ -1,4 +1,4 @@ export * from './shop-slice' -export * from './auth-slice' export * from './user-slice' +export * from './basket-slice' export * from './hooks' diff --git a/frontend/src/storage/shop-slice.ts b/frontend/src/storage/shop-slice.ts index 0fa1e43..843a704 100644 --- a/frontend/src/storage/shop-slice.ts +++ b/frontend/src/storage/shop-slice.ts @@ -1,13 +1,7 @@ -import { createSlice, type PayloadAction, createSelector } from '@reduxjs/toolkit' +import { createSlice, createSelector } from '@reduxjs/toolkit' import type { ProductType } from '../types' import type { ResponseData } from '../network' import { createAppAsyncThunk } from './hooks' - -interface AddBasketAction { - id: number - count: number -} - interface ShopState { products: ProductType[] loading: boolean @@ -23,16 +17,7 @@ const initialState: ShopState = { export const shopSlice = createSlice({ name: 'shop', initialState, - reducers: { - addToBasket: (state, action: PayloadAction) => { - for (const product of state.products) { - if (product.id === action.payload.id) { - product.count -= action.payload.count - product.reserved += action.payload.count - } - } - }, - }, + reducers: {}, extraReducers: builder => { builder .addCase(fetchProducts.pending, state => { diff --git a/frontend/src/storage/user-slice.ts b/frontend/src/storage/user-slice.ts index 28a4727..6a6298d 100644 --- a/frontend/src/storage/user-slice.ts +++ b/frontend/src/storage/user-slice.ts @@ -2,15 +2,32 @@ import { type PayloadAction, createSlice } from '@reduxjs/toolkit' import { type UserType } from '../types' import { createAppAsyncThunk } from './hooks' import type { ResponseData } from '../network' +import { + isActionPending, + isActionRejected, + token, + type PendingAction, + type RejectedAction, +} from '../utils' type UserState = { - user?: UserType + user: UserType loading: boolean error?: string } +const defaultUser: UserType = { + id: -1, + nickname: '', + email: '', + token: token.load(), + token_expiry_date: '', + money: '', + histories_id: [], +} + const initialState: UserState = { - user: undefined, + user: defaultUser, loading: false, error: undefined, } @@ -28,15 +45,47 @@ const userSlice = createSlice({ }, extraReducers: builder => { builder - .addCase(fetchUser.pending, state => { - state.loading = true + .addCase(fetchUser.fulfilled, (state, action) => { + state.user = action.payload.success?.[0] as UserType + state.loading = false + }) + .addCase(fetchLogin.fulfilled, (state, action) => { + state.user = action.payload.success?.[0] as UserType + state.loading = false state.error = undefined + token.save(state.user.token) }) - .addCase(fetchUser.fulfilled, (state, action) => { + .addCase(fetchLogout.fulfilled, state => { + if (state.user?.token) { + state.user.token = '' + } + state.loading = false + state.error = undefined + token.save('') + }) + .addCase(fetchRegister.fulfilled, (state, action) => { state.user = action.payload.success?.[0] as UserType state.loading = false + state.error = undefined + token.save(state.user.token) }) - .addCase(fetchUser.rejected, (state, action) => { + .addCase(fetchUnregister.fulfilled, (state, action) => { + state.user = action.payload.success?.[0] as UserType + state.loading = false + state.error = undefined + token.save(state.user.token) + }) + .addCase(fetchAddMoney.fulfilled, (state, action) => { + state.user = action.payload.success?.[0] as UserType + state.loading = false + state.error = undefined + token.save(state.user.token) + }) + .addMatcher(isActionPending('/user/'), state => { + state.loading = true + state.error = undefined + }) + .addMatcher(isActionRejected('/user/'), (state, action) => { state.loading = false if (action.payload) { state.error = (action.payload as ResponseData).error @@ -46,6 +95,7 @@ const userSlice = createSlice({ }) }, selectors: { + accessToken: (state: UserState) => state.user?.token, user: (state: UserState) => state.user, loading: (state: UserState) => state.loading, error: (state: UserState) => state.error, @@ -66,12 +116,109 @@ const fetchUser = createAppAsyncThunk( } ) +type RegisterProps = { + email: string + nickname: string + password: string +} + +const fetchRegister = createAppAsyncThunk( + `${userSlice.name}/fetchRegister`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/', { + method: 'POST', + body: JSON.stringify({ register: props }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +const fetchUnregister = createAppAsyncThunk( + `${userSlice.name}/fetchUnregister`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/', { + method: 'POST', + body: JSON.stringify({ unregister: {} }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +type LoginProps = { + email: string + password: string +} + +const fetchLogin = createAppAsyncThunk( + `${userSlice.name}/fetchLogin`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/', { + method: 'POST', + body: JSON.stringify({ login: props }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +const fetchLogout = createAppAsyncThunk( + `${userSlice.name}/fetchLogout`, + async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/', { + method: 'POST', + body: JSON.stringify({ logout: {} }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + +type AddMoneyProps = { + money: string +} + +const fetchAddMoney = createAppAsyncThunk( + `${userSlice.name}/fetchAddMoney`, + async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { + try { + const data = await networkApi.request('/user/', { + method: 'POST', + body: JSON.stringify({ add_money: props }), + }) + return fulfillWithValue(data) + } catch (error) { + return rejectWithValue(error) + } + } +) + class User { selector = { ...userSlice.selectors } action = userSlice.actions reducer = userSlice.reducer name = userSlice.name - fetch = { user: fetchUser } + fetch = { + user: fetchUser, + login: fetchLogin, + logout: fetchLogout, + register: fetchRegister, + unregister: fetchUnregister, + addMoney: fetchAddMoney, + } } export const user = new User() diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 35fee86..ad6f521 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,11 +1,11 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit' import { networkApi, type NetworkApi } from './network' -import { shop, auth, user } from './storage' +import { shop, user, basket } from './storage' const reducer = combineReducers({ - [auth.name]: auth.reducer, [shop.name]: shop.reducer, [user.name]: user.reducer, + [basket.name]: basket.reducer, }) export const store = configureStore({ diff --git a/frontend/src/types/basket.ts b/frontend/src/types/basket.ts new file mode 100644 index 0000000..f657c3e --- /dev/null +++ b/frontend/src/types/basket.ts @@ -0,0 +1,5 @@ +import type { ProductType } from './product' + +export type BasketType = ProductType & { + user_count: number +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7f1b33c..9d163a2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -2,3 +2,4 @@ export * from './product' export * from './error' export * from './clean-component' export * from './user' +export * from './basket' diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 0f78a76..57f6f34 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -3,7 +3,7 @@ export type UserType = { nickname: string email: string token: string - token_expiry_date: Date + token_expiry_date: string money: string histories_id: number[] } diff --git a/frontend/src/utils/action.ts b/frontend/src/utils/action.ts new file mode 100644 index 0000000..9330c24 --- /dev/null +++ b/frontend/src/utils/action.ts @@ -0,0 +1,64 @@ +import { store } from '../store' +import type { AsyncThunk, Dispatch, UnknownAction } from '@reduxjs/toolkit' + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch + +type GenericAsyncThunkConfig = { + /** return type for `thunkApi.getState` */ + state?: unknown + /** type for `thunkApi.dispatch` */ + dispatch?: Dispatch + /** type of the `extra` argument for the thunk middleware, which will be passed in as `thunkApi.extra` */ + extra?: unknown + /** type to be passed into `rejectWithValue`'s first argument that will end up on `rejectedAction.payload` */ + rejectValue?: unknown + /** return type of the `serializeError` option callback */ + serializedErrorType?: unknown + /** type to be returned from the `getPendingMeta` option callback & merged into `pendingAction.meta` */ + pendingMeta?: unknown + /** type to be passed into the second argument of `fulfillWithValue` to finally be merged into `fulfilledAction.meta` */ + fulfilledMeta?: unknown + /** type to be passed into the second argument of `rejectWithValue` to finally be merged into `rejectedAction.meta` */ + rejectedMeta?: unknown +} + +export type GenericAsyncThunk = AsyncThunk + +export type PendingAction = ReturnType['pending']> +export type RejectedAction = ReturnType['rejected']> +export type FulfilledAction = ReturnType['fulfilled']> + +export type ResultAction = PendingAction | RejectedAction | FulfilledAction + +export const hasPrefix = (action: UnknownAction, prefix: string): boolean => + action.type.startsWith(prefix) +export const isPending = (action: PendingAction): boolean => action.type.endsWith('/pending') +export const isFulfilled = (action: FulfilledAction): boolean => action.type.endsWith('/fulfilled') +export const isRejected = (action: RejectedAction): boolean => action.type.endsWith('/rejected') + +export const isActionPending = + (prefix: string) => + (action: PendingAction) => { + return hasPrefix(action, prefix) && isPending(action) + } +export const isActionRejected = + (prefix: string) => + (action: RejectedAction) => { + return hasPrefix(action, prefix) && isRejected(action) + } +export const isActionFulfilled = + (prefix: string) => + (action: FulfilledAction) => { + return hasPrefix(action, prefix) && isFulfilled(action) + } + +export const isFulfilledAction = (action: ResultAction): action is FulfilledAction => { + return action.type.endsWith('/fulfilled') +} +export const isRejectedAction = (action: ResultAction): action is RejectedAction => { + return action.type.endsWith('/rejected') +} +export const isPendingAction = (action: ResultAction): action is PendingAction => { + return action.type.endsWith('/pending') +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 96cac21..76267aa 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1 +1,2 @@ export * from './token' +export * from './action'