diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..dd030e9 Binary files /dev/null and b/.DS_Store differ diff --git a/e-commerce-app/src/App.css b/e-commerce-app/src/App.css index 74b5e05..7cf4dce 100644 --- a/e-commerce-app/src/App.css +++ b/e-commerce-app/src/App.css @@ -1,5 +1,5 @@ -.App { - text-align: center; +.app { + min-height: max-content; } .App-logo { diff --git a/e-commerce-app/src/App.tsx b/e-commerce-app/src/App.tsx index 4000d1c..39af6ea 100644 --- a/e-commerce-app/src/App.tsx +++ b/e-commerce-app/src/App.tsx @@ -6,11 +6,13 @@ import { useLocalToken } from './hooks/useLocalToken'; import { useAppDispatch, useAppSelector } from './store/hooks'; import { getAccessToken, setAuth, setLogIn, setLogOut } from './store/slices/userSlice'; import { useGetMyCustomerDetailsMutation } from './api/myCustomerApi'; +import LoadingProgress from './components/LoadingProgress/LoadingProgress'; export const App = () => { const [getAnonymousToken] = useGetAnonymousTokenMutation(); const { isTokenInStorage, getTokenFromStorage, delTokenFromStorage } = useLocalToken(); - const [getAccessTokenApi, { data, isSuccess, isError }] = useGetAccessTokenFromRefreshMutation(); + const [getAccessTokenApi, { data, isSuccess, isError, isLoading }] = + useGetAccessTokenFromRefreshMutation(); const [getDetails] = useGetMyCustomerDetailsMutation(); const dispatch = useAppDispatch(); const accessToken = useAppSelector(getAccessToken); @@ -52,6 +54,10 @@ export const App = () => { } }, [accessToken]); + if (isLoading) { + return ; + } + return ( <> diff --git a/e-commerce-app/src/api/categoriesApi.ts b/e-commerce-app/src/api/categoriesApi.ts new file mode 100644 index 0000000..daf435a --- /dev/null +++ b/e-commerce-app/src/api/categoriesApi.ts @@ -0,0 +1,27 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { IGetAllCategoriesResponse } from '../types/slicesTypes/categoriesApiTypes'; + +export const categoriesApi = createApi({ + reducerPath: 'categoriesApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${process.env.REACT_APP_CTP_API_URL}/${process.env.REACT_APP_CTP_PROJECT_KEY}/categories`, + }), + tagTypes: ['categories'], + endpoints: (build) => ({ + getAllCategories: build.query({ + query(token: string) { + return { + url: '', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + }, + providesTags: ['categories'], + }), + }), +}); + +export const { useGetAllCategoriesQuery } = categoriesApi; diff --git a/e-commerce-app/src/api/productProjectionApi.ts b/e-commerce-app/src/api/productProjectionApi.ts new file mode 100644 index 0000000..2abe0f4 --- /dev/null +++ b/e-commerce-app/src/api/productProjectionApi.ts @@ -0,0 +1,29 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { IGetAllProductsRequest } from '../types/slicesTypes/productsApiTypes'; +import { ISearchProductsResponse } from '../types/slicesTypes/productProjectionsApiTypes'; + +export const productProjectionApi = createApi({ + reducerPath: 'productProjectionApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${process.env.REACT_APP_CTP_API_URL}/${process.env.REACT_APP_CTP_PROJECT_KEY}/product-projections`, + }), + endpoints: (build) => ({ + searchProducts: build.mutation({ + query(queryObject) { + let path = ''; + if (queryObject.params && queryObject.params.resultPath) { + path = queryObject.params.resultPath; + } + return { + url: '/search' + path, + method: 'GET', + headers: { + Authorization: `Bearer ${queryObject.token}`, + }, + }; + }, + }), + }), +}); +// ,'variants.price.centAmount:range+(1200+to+*)'].join('&filter=') +export const { useSearchProductsMutation } = productProjectionApi; diff --git a/e-commerce-app/src/api/productsApi.ts b/e-commerce-app/src/api/productsApi.ts new file mode 100644 index 0000000..8ed314b --- /dev/null +++ b/e-commerce-app/src/api/productsApi.ts @@ -0,0 +1,45 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { + IGetAllProductsRequest, + IGetAllProductsResponse, + IGetProductByIdRequest, + IProductApiResponse, +} from '../types/slicesTypes/productsApiTypes'; + +export const productsApi = createApi({ + reducerPath: 'productsApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${process.env.REACT_APP_CTP_API_URL}/${process.env.REACT_APP_CTP_PROJECT_KEY}/products`, + }), + endpoints: (build) => ({ + getAllProducts: build.mutation({ + query(queryObject) { + return { + url: '', + method: 'GET', + params: { + ...queryObject.params, + }, + headers: { + Authorization: `Bearer ${queryObject.token}`, + 'Content-Type': 'application/json', + }, + }; + }, + }), + getProductById: build.query({ + query(queryObject) { + return { + url: `/${queryObject.productId}`, + method: 'GET', + headers: { + Authorization: `Bearer ${queryObject.token}`, + 'Content-Type': 'application/json', + }, + }; + }, + }), + }), +}); + +export const { useGetAllProductsMutation, useGetProductByIdQuery } = productsApi; diff --git a/e-commerce-app/src/api/taxApi.ts b/e-commerce-app/src/api/taxApi.ts new file mode 100644 index 0000000..99e6735 --- /dev/null +++ b/e-commerce-app/src/api/taxApi.ts @@ -0,0 +1,24 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { IGetAllTaxesResponse } from '../types/slicesTypes/taxApiTypes'; + +export const taxApi = createApi({ + reducerPath: 'taxApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${process.env.REACT_APP_CTP_API_URL}/${process.env.REACT_APP_CTP_PROJECT_KEY}/tax-categories`, + }), + endpoints: (build) => ({ + getAllTaxes: build.query({ + query(token: string) { + return { + url: '', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }; + }, + }), + }), +}); + +export const { useGetAllTaxesQuery } = taxApi; diff --git a/e-commerce-app/src/assets/images/EmptyCatalogPage.svg b/e-commerce-app/src/assets/images/EmptyCatalogPage.svg new file mode 100644 index 0000000..f3c5dbe --- /dev/null +++ b/e-commerce-app/src/assets/images/EmptyCatalogPage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e-commerce-app/src/assets/images/ProductsPageImg.png b/e-commerce-app/src/assets/images/ProductsPageImg.png new file mode 100644 index 0000000..353b37a Binary files /dev/null and b/e-commerce-app/src/assets/images/ProductsPageImg.png differ diff --git a/e-commerce-app/src/assets/images/TestImg-1.png b/e-commerce-app/src/assets/images/TestImg-1.png new file mode 100644 index 0000000..fad3a89 Binary files /dev/null and b/e-commerce-app/src/assets/images/TestImg-1.png differ diff --git a/e-commerce-app/src/assets/images/TestImg-2.jpeg b/e-commerce-app/src/assets/images/TestImg-2.jpeg new file mode 100644 index 0000000..3d802f8 Binary files /dev/null and b/e-commerce-app/src/assets/images/TestImg-2.jpeg differ diff --git a/e-commerce-app/src/assets/images/TestImg.jpeg b/e-commerce-app/src/assets/images/TestImg.jpeg new file mode 100644 index 0000000..15493c7 Binary files /dev/null and b/e-commerce-app/src/assets/images/TestImg.jpeg differ diff --git a/e-commerce-app/src/assets/images/UserPageImg.png b/e-commerce-app/src/assets/images/UserPageImg.png new file mode 100644 index 0000000..3ccd1f5 Binary files /dev/null and b/e-commerce-app/src/assets/images/UserPageImg.png differ diff --git a/e-commerce-app/src/components/AddressItem/AddressItem.tsx b/e-commerce-app/src/components/AddressItem/AddressItem.tsx new file mode 100644 index 0000000..3ad171e --- /dev/null +++ b/e-commerce-app/src/components/AddressItem/AddressItem.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; +import { Typography, Box, Paper, IconButton } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import type { Board } from '../../pages/UserPage/UserAddresses'; +import { IMyCustomerApiAddressRequest } from '../../types/addressesTypes'; + +interface addressItemProps { + todo: Board; + onDeleteAddr: (id: Board['id']) => void; + onCheckAddr: (id: Board['id']) => void; + onEdit: (id: Board['id']) => void; + address: IMyCustomerApiAddressRequest | undefined; +} + +export const AddressItem: FC = ({ + todo, + onDeleteAddr, + onCheckAddr, + onEdit, + address, +}) => ( + + + onCheckAddr(todo.id)} + sx={{ cursor: 'pointer', textDecorationLine: todo.checked ? 'line-through' : 'none' }} + variant="h6" + component="h6" + gutterBottom + > + {todo.name} + + + + {address && ( + + {address.streetName}, {address.city}, {address.country}, {address.postalCode} + + )} + + + onEdit(todo.id)} color="primary" aria-label="edit"> + + + onDeleteAddr(todo.id)} color="error" aria-label="delete"> + + + + +); diff --git a/e-commerce-app/src/components/AddressList/AddressList.tsx b/e-commerce-app/src/components/AddressList/AddressList.tsx new file mode 100644 index 0000000..c06a5c0 --- /dev/null +++ b/e-commerce-app/src/components/AddressList/AddressList.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react'; +import { Box } from '@mui/material'; +import { AddressItem } from '../AddressItem/AddressItem'; +import type { Board } from '../../pages/UserPage/UserAddresses'; +import { AddressPanel } from '../Panel/Panel'; +import { IMyCustomerApiAddressRequest } from '../../types/addressesTypes'; + +interface TodoListProps { + editTodoId: Board['id'] | null; + todoList: Board[]; + onDeleteAddr: (id: Board['id']) => void; + onCheckAddr: (id: Board['id']) => void; + onEdit: (id: Board['id']) => void; + onChangeAddr: ({ name, street, city, country, postcode }: Omit) => void; + address: IMyCustomerApiAddressRequest | undefined; +} + +export const BoardList: FC = ({ + todoList, + editTodoId, + onChangeAddr, + onDeleteAddr, + onCheckAddr, + onEdit, + address, +}) => ( + + {todoList.map((todo) => { + if (todo.id === editTodoId) + return ; + return ( + + ); + })} + +); diff --git a/e-commerce-app/src/components/Header/Header.tsx b/e-commerce-app/src/components/Header/Header.tsx index 91537e0..2fa27b0 100644 --- a/e-commerce-app/src/components/Header/Header.tsx +++ b/e-commerce-app/src/components/Header/Header.tsx @@ -83,11 +83,11 @@ export const Header: React.FC = () => { - + { + return ( + + + + Loading... + + + ); +}; +export default LoadingProgress; diff --git a/e-commerce-app/src/components/MenuLinks/MenuLinks.tsx b/e-commerce-app/src/components/MenuLinks/MenuLinks.tsx index 46e9179..3514a99 100644 --- a/e-commerce-app/src/components/MenuLinks/MenuLinks.tsx +++ b/e-commerce-app/src/components/MenuLinks/MenuLinks.tsx @@ -16,7 +16,7 @@ const MenuLinks: FC = ({ navigation, handler }): JSX.Element => <> {Object.entries(navigation).map(([title, path]) => ( - diff --git a/e-commerce-app/src/components/Panel/Panel.tsx b/e-commerce-app/src/components/Panel/Panel.tsx new file mode 100644 index 0000000..1bf0161 --- /dev/null +++ b/e-commerce-app/src/components/Panel/Panel.tsx @@ -0,0 +1,110 @@ +import { FC, useState } from 'react'; +import AddIcon from '@mui/icons-material/Add'; +import { TextField, Paper, Button, Box, Grid } from '@mui/material'; +import type { Board } from '../../pages/UserPage/UserAddresses'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +const DEFAULT_TODO = { name: '', street: '', city: '', country: '', postcode: '' }; + +interface AddBoardPanelProps { + mode: 'add'; + onAddAddress: ({ name, street, city, country, postcode }: Omit) => void; +} + +interface EditBoardPanelProps { + mode: 'edit'; + editTodo: Omit; + onChangeAddr: ({ name, street, city, country, postcode }: Omit) => void; +} + +type PanelProps = AddBoardPanelProps | EditBoardPanelProps; + +export const AddressPanel: FC = (props) => { + const isEdit = props.mode === 'edit'; + const [todo, setTodo] = useState(isEdit ? props.editTodo : DEFAULT_TODO); + + const onClick = () => { + if (isEdit) { + return props.onChangeAddr(todo); + } + props.onAddAddress(todo); + setTodo(DEFAULT_TODO); + }; + + const onChange = (event: React.ChangeEvent) => { + const { value, name } = event.target; + setTodo({ ...todo, [name]: value }); + }; + + return ( + + + + + + + + + + + + + + + + + + + + } + label={ + + use asdefault address + + } + labelPlacement="end" + /> + + + + ); +}; diff --git a/e-commerce-app/src/components/ProductCard/ProductCard.module.scss b/e-commerce-app/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 0000000..25a3d8e --- /dev/null +++ b/e-commerce-app/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,52 @@ +.card { + display: flex; + width: 200px; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding: 1rem; + border: 2px green double; + transition: box-shadow .3s ease-in-out; + cursor: pointer; + + &:hover { + box-shadow: 8px 10px 5px 2px #14763033; + transition: box-shadow .3s ease-in-out; + } + + + &__text { + height: 100%; + display: flex; + align-items: center; + flex-wrap: wrap; + } + + &__title { + width: 100%; + text-align: center; + color: cadetblue; + font-weight: 900; + } + + &__price { + padding-top: 1rem; + width: 50%; + font-weight: 700; + text-align: center; + + &__marked { + color: rgb(221, 65, 65); + padding-top: 1rem; + width: 50%; + font-weight: 700; + text-align: center; + } + + &__sale { + text-decoration: line-through; + text-decoration-color: gray; + padding-top: 1rem; + } + } +} \ No newline at end of file diff --git a/e-commerce-app/src/components/ProductCard/ProductCard.tsx b/e-commerce-app/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000..eb498c9 --- /dev/null +++ b/e-commerce-app/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,110 @@ +import { FC, useEffect, useState } from 'react'; +import styles from './ProductCard.module.scss'; +import { Box, Typography, Button, CardMedia, CardContent, CardActions, Card } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { IProductApiResponse } from '../../types/slicesTypes/productsApiTypes'; +import { getTaxes } from '../../store/slices/taxesSlice'; +import { useAppSelector } from '../../store/hooks'; +import { ITaxApiResponse } from '../../types/slicesTypes/taxApiTypes'; + +interface ICardProps { + item: IProductApiResponse; +} + +export const ProductCard: FC = ({ item }) => { + const navigate = useNavigate(); + const handlerNavigation = () => { + navigate(`/products/${item.id}`); + }; + + const taxesArray = useAppSelector(getTaxes); + + const [tax, setTax] = useState(0); + + useEffect(() => { + taxesArray + .filter((i) => i.id === item.taxCategory.id && i.key === 'sale') + .flatMap((elem) => elem.rates) + .filter((rate: ITaxApiResponse) => rate.country === 'DE') + .forEach((rate: ITaxApiResponse) => { + setTax(rate.amount); + }); + }, [item]); + + const imgPath = item.masterData.current.masterVariant.images[0].url; + const imgDescription = item.masterData.current.name.en; + + const currencyEUR = item.masterData.current.masterVariant.prices[0].value.currencyCode; + const numberEUR = + item.masterData.current.masterVariant.prices[0].value.centAmount / + 10 ** item.masterData.current.masterVariant.prices[0].value.fractionDigits; + const priceEUR = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyEUR, + }).format(numberEUR); + const salePriceEUR = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyEUR, + }).format(numberEUR - numberEUR * tax); + + const currencyUSD = item.masterData.current.masterVariant.prices[1].value.currencyCode; + const numberUSD = + item.masterData.current.masterVariant.prices[1].value.centAmount / + 10 ** item.masterData.current.masterVariant.prices[1].value.fractionDigits; + const priceUSD = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyUSD, + }).format(numberUSD); + const salePriceUSD = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyUSD, + }).format(numberUSD - numberUSD * tax); + + return ( + + + + + + + {item.masterData.current.name.en} + + + {tax !== 0 ? ( + <> + + {salePriceEUR} + + + {priceEUR} + + + ) : ( + + {priceEUR} + + )} + + {tax !== 0 ? ( + <> + + {salePriceUSD} + + + {priceUSD} + + + ) : ( + + {priceUSD} + + )} + + + + + + ); +}; diff --git a/e-commerce-app/src/components/ProductsFilterForm/ProductsFilterForm.tsx b/e-commerce-app/src/components/ProductsFilterForm/ProductsFilterForm.tsx new file mode 100644 index 0000000..5fbbb5b --- /dev/null +++ b/e-commerce-app/src/components/ProductsFilterForm/ProductsFilterForm.tsx @@ -0,0 +1,159 @@ +import React, { JSX, useState } from 'react'; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material'; +import Slider from '@mui/material/Slider'; +import { useAppSelector } from '../../store/hooks'; +import { getAllCategories } from '../../store/slices/categoriesSlice'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { ISortByForm, SortFormType } from '../../types/searchProductsTypes/filterFormTypes'; +import { useDispatch } from 'react-redux'; +import { + getQueryCategories, + getQueryCentAmount, + getQuerySort, + setEmptySort, + setQueryCategories, + setQueryCentAmount, + setQuerySort, + setQueryText, +} from '../../store/slices/queryParamsSlice'; + +const minDistance = 10; +const ProductsFilterForm = (): JSX.Element => { + const dispatch = useDispatch(); + const searchQuerySort = useAppSelector(getQuerySort); + const searchQueryCategories = useAppSelector(getQueryCategories); + const categories = useAppSelector(getAllCategories); + const centAmount = useAppSelector(getQueryCentAmount); + + const [priceSort, setPriceSort] = React.useState(centAmount); + const [sortRate, setSortRate] = useState(searchQuerySort); + const [sortCategories, setSortCategories] = useState(searchQueryCategories); + + const handleChange2 = (event: Event, newValue: number | number[], activeThumb: number) => { + if (!Array.isArray(newValue)) { + return; + } + + if (newValue[1] - newValue[0] < minDistance) { + if (activeThumb === 0) { + const clamped = Math.min(newValue[0], 100 - minDistance); + setPriceSort([clamped, clamped + minDistance]); + } else { + const clamped = Math.max(newValue[1], minDistance); + setPriceSort([clamped - minDistance, clamped]); + } + } else { + setPriceSort(newValue as number[]); + } + }; + + const { register, handleSubmit, reset } = useForm({ + defaultValues: { + sort: sortRate, + }, + }); + + const submitFormHandler: SubmitHandler = (data) => { + if (data.sort) { + dispatch(setQuerySort(data.sort)); + } + if (!(priceSort[0] === 0 && priceSort[1] === 100)) { + dispatch(setQueryCentAmount(priceSort)); + } + if (data.categories) { + dispatch(setQueryCategories(data.categories)); + } + if (!data.sort && priceSort[0] === 0 && priceSort[1] === 100 && !data.categories) { + dispatch(setEmptySort()); + } + }; + + const clearSortHandler = () => { + dispatch(setEmptySort()); + dispatch(setQueryText('')); + reset(); + setSortRate(''); + setSortCategories(''); + }; + + return ( + + + + + Product Categories + + Categories + + + + + Filter by price + + +

{priceSort[0]}

+ +

{priceSort[1]}

+
+
+
+ + + Sort By + + + + +
+
+ ); +}; +export default ProductsFilterForm; diff --git a/e-commerce-app/src/components/ProductsList/EmptyProducts.tsx b/e-commerce-app/src/components/ProductsList/EmptyProducts.tsx new file mode 100644 index 0000000..f8b30ae --- /dev/null +++ b/e-commerce-app/src/components/ProductsList/EmptyProducts.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import styles from './ProductsList.module.scss'; +import { Box } from '@mui/material'; +import empty from '../../assets/images/EmptyCatalogPage.svg'; + +export const EmptyProducts: FC = () => { + return ( + + This is an empty product list. There are no products available! + home-icon + + ); +}; diff --git a/e-commerce-app/src/components/ProductsList/ProductsList.module.scss b/e-commerce-app/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 0000000..c6403f8 --- /dev/null +++ b/e-commerce-app/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,18 @@ +.container { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1.5rem; +} + +.empty__products { + display: flex; + flex-direction: column; + align-items: center; + color: gray; + font-size: 25px; +} + +.img { + margin-top: 50px; +} \ No newline at end of file diff --git a/e-commerce-app/src/components/ProductsList/ProductsList.tsx b/e-commerce-app/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 0000000..e737d07 --- /dev/null +++ b/e-commerce-app/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,21 @@ +import { Box } from '@mui/material'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductsList.module.scss'; +import { useAppSelector } from '../../store/hooks'; +import { getProducts } from '../../store/slices/productsSlice'; +import { FC } from 'react'; +import { EmptyProducts } from './EmptyProducts'; + +export const ProductsList: FC = () => { + const products = useAppSelector(getProducts); + + return ( + + {products.length > 0 ? ( + products.map((item) => ) + ) : ( + + )} + + ); +}; diff --git a/e-commerce-app/src/components/UserRedirect/UserRedirect.tsx b/e-commerce-app/src/components/UserRedirect/UserRedirect.tsx new file mode 100644 index 0000000..2bce03e --- /dev/null +++ b/e-commerce-app/src/components/UserRedirect/UserRedirect.tsx @@ -0,0 +1,13 @@ +import { JSX } from 'react'; +import { useAppSelector } from '../../store/hooks'; +import { getMyCustomerId } from '../../store/slices/myCustomerSlice'; +import { Navigate } from 'react-router-dom'; +import LoadingProgress from '../LoadingProgress/LoadingProgress'; + +const UserRedirect = (): JSX.Element => { + const myCustomerId = useAppSelector(getMyCustomerId); + + if (!myCustomerId) return ; + return ; +}; +export default UserRedirect; diff --git a/e-commerce-app/src/hooks/useValidate.tsx b/e-commerce-app/src/hooks/useValidate.tsx index ccb4044..0d46976 100644 --- a/e-commerce-app/src/hooks/useValidate.tsx +++ b/e-commerce-app/src/hooks/useValidate.tsx @@ -26,6 +26,7 @@ const defaultErrorsObj: IRegistrationFormData = { postalCodeBilling: null, streetAddressBilling: null, streetAddressShipping: null, + currentPassword: null, }; export const useValidate = () => { diff --git a/e-commerce-app/src/index.tsx b/e-commerce-app/src/index.tsx index 63e2335..9c2f94a 100644 --- a/e-commerce-app/src/index.tsx +++ b/e-commerce-app/src/index.tsx @@ -13,11 +13,9 @@ import { store } from './store/store'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - - - - - , + + + , ); // If you want to start measuring performance in your app, pass a function diff --git a/e-commerce-app/src/interfaces/IRegistrationFormData.ts b/e-commerce-app/src/interfaces/IRegistrationFormData.ts index 4b828e4..bd7b81b 100644 --- a/e-commerce-app/src/interfaces/IRegistrationFormData.ts +++ b/e-commerce-app/src/interfaces/IRegistrationFormData.ts @@ -13,4 +13,5 @@ export interface IRegistrationFormData { cityBilling: string | null; postalCodeShipping: string | null; postalCodeBilling: string | null; + currentPassword: string | null; } diff --git a/e-commerce-app/src/interfaces/IUserProps.ts b/e-commerce-app/src/interfaces/IUserProps.ts new file mode 100644 index 0000000..21d3c07 --- /dev/null +++ b/e-commerce-app/src/interfaces/IUserProps.ts @@ -0,0 +1,20 @@ +import { IRegistrationFormData } from './IRegistrationFormData'; +import { UseFormRegister, UseFormSetValue } from 'react-hook-form'; +import { IValues } from './IValues'; +import { globalErrors } from '../types'; +import { IMyCustomerApiAddressRequest } from '../types/addressesTypes'; + +export interface IUserProps { + register: UseFormRegister; + validationHandler: ( + fieldName: keyof IRegistrationFormData, + value: string, + values?: IValues, + ) => void; + errors: globalErrors; + userData?: string[]; + userAddresses?: (IMyCustomerApiAddressRequest[] | string[] | string | undefined)[]; + setValue: UseFormSetValue; + password?: string; + getValues?: (fieldName: string) => string | undefined; +} diff --git a/e-commerce-app/src/pages/ProductPage/ProductPage.module.scss b/e-commerce-app/src/pages/ProductPage/ProductPage.module.scss new file mode 100644 index 0000000..99478e9 --- /dev/null +++ b/e-commerce-app/src/pages/ProductPage/ProductPage.module.scss @@ -0,0 +1,89 @@ +.left { + display: flex; + align-items: center; + flex: 1; + gap: 15px; + + .images { + flex: 1; + min-width: max-content; + + img { + width: 100%; + height: 100px; + margin-bottom: 15px; + + } + } +} + +.img { + &_small { + object-fit: contain; + cursor: pointer; + } + + &_big { + width: 100%; + object-fit: contain; + max-height: 50vh; + cursor: pointer; + } +} + +.price { + font-weight: 500; + color: green; + + &_full { + text-decoration: line-through; + color: green; + text-decoration-color: gray; + } +} + +.quantity { + display: flex; + align-items: center; + gap: 20px; + + button { + background-color: greenyellow; + cursor: pointer; + } +} + +.btn { + button { + width: 250px; + padding: 10px; + display: flex; + justify-content: center; + font-weight: 700; + background-color: green; + color: white; + border: 1px solid green; + border-radius: 10px; + cursor: pointer; + + &:hover { + color: green; + border: 1px solid green; + } + } +} + +.modal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 2px solid #000; + max-height: 70vh; + max-width: 70vw; + + &__img { + max-height: 60vh; + object-fit: contain; + } +} \ No newline at end of file diff --git a/e-commerce-app/src/pages/ProductPage/ProductPage.tsx b/e-commerce-app/src/pages/ProductPage/ProductPage.tsx index 7e71688..ecaac96 100644 --- a/e-commerce-app/src/pages/ProductPage/ProductPage.tsx +++ b/e-commerce-app/src/pages/ProductPage/ProductPage.tsx @@ -1,5 +1,227 @@ -import React from 'react'; +import { FC, useEffect, useState } from 'react'; +import styles from './ProductPage.module.scss'; +import { Box, Typography, ButtonGroup, Button, Grid, Stack } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import RemoveIcon from '@mui/icons-material/Remove'; +import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; +import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart'; +import Modal from '@mui/material/Modal'; +import CloseIcon from '@mui/icons-material/Close'; +import IconButton from '@mui/material/IconButton'; +import { ArrowBackIos, ArrowForwardIos } from '@mui/icons-material'; +import { useGetProductByIdQuery } from '../../api/productsApi'; +import { useParams } from 'react-router-dom'; +import { useAppSelector } from '../../store/hooks'; +import { getAccessToken } from '../../store/slices/userSlice'; +import LoadingProgress from '../../components/LoadingProgress/LoadingProgress'; +import { getTaxes } from '../../store/slices/taxesSlice'; +import { ITaxApiResponse } from '../../types/slicesTypes/taxApiTypes'; -export const ProductPage: React.FC = () => { - return
Product Page
; +const style = { + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, +}; + +const styleArrows = { + display: 'flex', + justifyContent: 'space-around', +}; + +export const ProductPage: FC = () => { + const { productId } = useParams(); + const authToken = useAppSelector(getAccessToken); + const taxesArray = useAppSelector(getTaxes); + + const { data, isSuccess, isLoading, isFetching } = useGetProductByIdQuery({ + productId: productId as string, + token: authToken as string, + }); + + const [tax, setTax] = useState(0); + + useEffect(() => { + if (isSuccess) { + taxesArray + .filter((item) => item.id === data.taxCategory.id && item.key === 'sale') + .flatMap((elem) => elem.rates) + .filter((rate: ITaxApiResponse) => rate.country === 'DE') + .forEach((rate: ITaxApiResponse) => { + setTax(rate.amount); + }); + } + }, [isSuccess]); + + const [selectedImg, setSelectedImg] = useState(0); + + const [count, setCount] = useState(1); + + // Modal window + + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + if (isLoading || isFetching || !data) { + return ; + } + + const images = data.masterData.current.masterVariant.images.map((img) => img.url); + const title = data.masterData.current.name.en; + const description = data.masterData.current.metaDescription.en; + + const currencyCommon = data.masterData.current.masterVariant.prices[0].value.currencyCode; + const priceNumber = + data.masterData.current.masterVariant.prices[0].value.centAmount / + 10 ** data.masterData.current.masterVariant.prices[0].value.fractionDigits; + const priceCommon = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyCommon, + }).format(priceNumber); + + const discountPrice = new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: currencyCommon, + }).format(priceNumber - priceNumber * tax); + + return ( + + + + + + {images.map((item, idx) => ( + {title} setSelectedImg(idx)} + /> + ))} + + + + + img3 + + + + + + + + + img3 + + + + selectedImg === 0 + ? setSelectedImg(0) + : setSelectedImg((prevState) => prevState - 1) + } + disabled={selectedImg === 0} + > + + Prev + + + selectedImg === images.length - 1 + ? setSelectedImg((prevState) => prevState) + : setSelectedImg((prevState) => prevState + 1) + } + disabled={selectedImg === images.length - 1} + > + Next + + + + + + + + + + + {title} + + {tax !== 0 ? ( + <> + + Discount price:{discountPrice} + + + Full price:{priceCommon} + + + Tax: {`${tax * 100} %`} + + + ) : ( + + Price:{priceCommon} + + )} + + {description} + + + + + + {count} + + + + + + + + + + + + + + + ); }; diff --git a/e-commerce-app/src/pages/ProductsPage/ProductsPage.module.scss b/e-commerce-app/src/pages/ProductsPage/ProductsPage.module.scss new file mode 100644 index 0000000..3510602 --- /dev/null +++ b/e-commerce-app/src/pages/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,35 @@ +.top { + display: flex; + width: 100%; + min-height: min-content; + margin-top: 30px; + + &__img { + height: fit-content; + } + + &__form { + height: fit-content; + margin: auto; + background-color: beige; + padding: 2px 4px; + display: flex; + align-items: center; + max-width: 25rem; + } +} + +.left { + padding-left: 1rem; + margin-left: 1rem; + color: green; + display: flex; + flex-direction: column; + justify-content: space-evenly; +} + +.right { + padding: 0.5rem; + margin: 0.5rem; +} + diff --git a/e-commerce-app/src/pages/ProductsPage/ProductsPage.tsx b/e-commerce-app/src/pages/ProductsPage/ProductsPage.tsx index 0fd36c3..f36408e 100644 --- a/e-commerce-app/src/pages/ProductsPage/ProductsPage.tsx +++ b/e-commerce-app/src/pages/ProductsPage/ProductsPage.tsx @@ -1,5 +1,68 @@ import React from 'react'; +import { Grid, Box, Divider, InputBase, IconButton, Paper } from '@mui/material'; +import ProductsPageImg from '../../assets/images/ProductsPageImg.png'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import SearchIcon from '@mui/icons-material/Search'; +import styles from './ProductsPage.module.scss'; +import ProductsFilterForm from '../../components/ProductsFilterForm/ProductsFilterForm'; +import { useDispatch } from 'react-redux'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { ISearchProductForm } from '../../types/searchProductsTypes/searchFormTypes'; +import { getQueryText, setQueryText } from '../../store/slices/queryParamsSlice'; +import { useAppSelector } from '../../store/hooks'; export const ProductsPage: React.FC = () => { - return
Products Page
; + const dispatch = useDispatch(); + const searchQueryText = useAppSelector(getQueryText); + const { register, handleSubmit } = useForm({ + defaultValues: { + query: searchQueryText, + }, + }); + const submitHandler: SubmitHandler = (data) => { + dispatch(setQueryText(data.query || '')); + }; + return ( + + + + + img1 + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/e-commerce-app/src/pages/UserPage/UserAddresses.tsx b/e-commerce-app/src/pages/UserPage/UserAddresses.tsx new file mode 100644 index 0000000..f3abe0e --- /dev/null +++ b/e-commerce-app/src/pages/UserPage/UserAddresses.tsx @@ -0,0 +1,164 @@ +import { FC, useState } from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import { AddressPanel } from '../../components/Panel/Panel'; +import { BoardList } from '../../components/AddressList/AddressList'; +import { IUserProps } from '../../interfaces/IUserProps'; +import { IMyCustomerApiAddressRequest } from '../../types/addressesTypes'; + +export type Board = { + id: number; + name: string; + street: string; + city: string; + country: string; + postcode: string; + checked: boolean; +}; + +const DEFAULT_TODO_LIST = [ + { + id: 1, + name: 'Address 1', + street: 'street 1', + city: 'city 1', + country: 'country 1', + postcode: 'code 1', + checked: false, + }, +]; + +export const UserAddresses: FC = ({ + register, + validationHandler, + errors, + userAddresses, + setValue, +}) => { + if (!userAddresses) { + return null; + } + + let shippingAddress; + let billingAddress; + + if ( + userAddresses[0] !== undefined && + userAddresses[1] !== undefined && + Array.isArray(userAddresses[0]) && + Array.isArray(userAddresses[1]) + ) { + shippingAddress = (userAddresses[0] as IMyCustomerApiAddressRequest[]).find((item) => { + return ( + typeof item === 'object' && + item !== null && + 'id' in item && + item.id === (userAddresses[1] as string[])[0] + ); + }); + + billingAddress = (userAddresses[0] as IMyCustomerApiAddressRequest[]).find((item) => { + return ( + typeof item === 'object' && + item !== null && + 'id' in item && + item.id === (userAddresses[2] as string[])[0] + ); + }); + } + + const [editTodoId, setEditTodoId] = useState(null); + const [todoList, setTodoList] = useState(DEFAULT_TODO_LIST); + + const onEdit = (id: Board['id']) => { + setEditTodoId(id); + }; + + const onDeleteAddr = (id: Board['id']) => { + setTodoList(todoList.filter((todo) => todo.id !== id)); + }; + + const onAddAddress = ({ + name, + street, + city, + country, + postcode, + }: Omit) => { + setTodoList([ + ...todoList, + { + id: todoList[todoList.length - 1].id + 1, + name, + street, + city, + country, + postcode, + checked: false, + }, + ]); + }; + + const onCheckAddr = (id: Board['id']) => { + setTodoList( + todoList.map((todo) => { + if (todo.id === id) { + return { ...todo, checked: !todo.checked }; + } + return todo; + }), + ); + }; + + const onChangeAddr = ({ + name, + street, + city, + country, + postcode, + }: Omit) => { + setTodoList( + todoList.map((todo) => { + if (todo.id === editTodoId) { + return { ...todo, name, street, city, country, postcode }; + } + return todo; + }), + ); + setEditTodoId(null); + }; + + return ( + + + + Shipping adresses: {todoList.length} + + + + + + + Billing adresses: {todoList.length} + + + + + + ); +}; diff --git a/e-commerce-app/src/pages/UserPage/UserData.tsx b/e-commerce-app/src/pages/UserPage/UserData.tsx new file mode 100644 index 0000000..bc8949b --- /dev/null +++ b/e-commerce-app/src/pages/UserPage/UserData.tsx @@ -0,0 +1,163 @@ +import TextField from '@mui/material/TextField'; +import Grid from '@mui/material/Grid'; +import { FC, useState, useEffect } from 'react'; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; +import EditIcon from '@mui/icons-material/Edit'; +import styles from './UserPage.module.scss'; +import { IUserProps } from '../../interfaces/IUserProps'; + +export const UserData: FC = ({ + register, + validationHandler, + errors, + userData, + setValue, +}) => { + const [isEditableArray, setIsEditableArray] = useState([false, false, false, false]); + + const [inputValues, setInputValues] = useState({ + firstName: userData?.[0] || '', + lastName: userData?.[1] || '', + birthDate: userData?.[2] || '', + email: userData?.[3] || '', + }); + + const handleInputChange = (field: string, value: string) => { + setInputValues((prevInputValues) => ({ + ...prevInputValues, + [field]: value, + })); + }; + + useEffect(() => { + setValue('firstName', inputValues.firstName); + setValue('lastName', inputValues.lastName); + setValue('birthDate', inputValues.birthDate); + setValue('email', inputValues.email); + }, [inputValues, setValue]); + + const handleEditClick = (index: number) => { + const newIsEditableArray = [...isEditableArray]; + newIsEditableArray[index] = !newIsEditableArray[index]; + setIsEditableArray(newIsEditableArray); + }; + + return ( + + + { + const newValue = e.target.value; + handleInputChange('firstName', newValue); + if (isEditableArray[0] && newValue !== inputValues.firstName) { + validationHandler('firstName', newValue); + } + }, + })} + InputProps={{ + className: !isEditableArray[0] ? styles.non__editable : '', + }} + disabled={!isEditableArray[0]} + /> + + + + { + const newValue = e.target.value; + handleInputChange('lastName', newValue); + if (isEditableArray[1] && newValue !== inputValues.lastName) { + validationHandler('lastName', newValue); + } + }, + })} + InputProps={{ + readOnly: !isEditableArray[1], + className: !isEditableArray[1] ? styles.non__editable : '', + }} + disabled={!isEditableArray[1]} + /> + + + + { + const newValue = e.target.value; + handleInputChange('birthDate', newValue); + if (isEditableArray[2] && newValue !== inputValues.birthDate) { + validationHandler('birthDate', newValue); + } + }, + })} + InputProps={{ + readOnly: !isEditableArray[2], + className: !isEditableArray[2] ? styles.non__editable : '', + }} + disabled={!isEditableArray[2]} + /> + + + + { + const newValue = e.target.value; + handleInputChange('email', newValue); + if (isEditableArray[3] && newValue !== inputValues.email) { + validationHandler('email', newValue); + } + }, + })} + InputProps={{ + readOnly: !isEditableArray[3], + className: !isEditableArray[3] ? styles.non__editable : '', + }} + disabled={!isEditableArray[3]} + /> + + + + ); +}; diff --git a/e-commerce-app/src/pages/UserPage/UserPage.module.scss b/e-commerce-app/src/pages/UserPage/UserPage.module.scss new file mode 100644 index 0000000..24acbe3 --- /dev/null +++ b/e-commerce-app/src/pages/UserPage/UserPage.module.scss @@ -0,0 +1,10 @@ +.non__editable { + + background-color: #f3eded; + + &__icon { + stroke: gray; + fill: gray !important; + } + +} \ No newline at end of file diff --git a/e-commerce-app/src/pages/UserPage/UserPage.tsx b/e-commerce-app/src/pages/UserPage/UserPage.tsx index 913ce7e..fada6f8 100644 --- a/e-commerce-app/src/pages/UserPage/UserPage.tsx +++ b/e-commerce-app/src/pages/UserPage/UserPage.tsx @@ -1,5 +1,287 @@ -import React from 'react'; +import React, { useState } from 'react'; +import CssBaseline from '@mui/material/CssBaseline'; +import Container from '@mui/material/Container'; +import Box from '@mui/material/Box'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { Grid } from '@mui/material'; +import Alert from '@mui/material/Alert'; +import { UserData } from './UserData'; +import { UserAddresses } from './UserAddresses'; +import { UserPassword } from './UserPassword'; +import { useValidate } from '../../hooks/useValidate'; +import { IRegistrationFormData } from '../../interfaces/IRegistrationFormData'; +import { useForm, SubmitHandler, FieldErrors } from 'react-hook-form'; +import { fieldNameType, globalErrors } from '../../types'; +import { IValues } from '../../interfaces/IValues'; +import { useAppSelector } from '../../store/hooks'; +import { + getMyCustomerFirstName, + getMyCustomerLastName, + getMyCustomerDateOfBirth, + getMyCustomerEmail, + getMyCustomerAddresses, + getMyCustomerShippingAddressIds, + getMyCustomerBillingAddressIds, + getMyCustomerDefaultShippingAddressId, + getMyCustomerDefaultBillingAddressId, + getMyCustomerPassword, +} from '../../store/slices/myCustomerSlice'; + +const steps = ['Personal information', 'Shipping/Billing address', 'Change password']; export const UserPage: React.FC = () => { - return
User Page
; + const firstName = useAppSelector(getMyCustomerFirstName); + const lastName = useAppSelector(getMyCustomerLastName); + const birthDate = useAppSelector(getMyCustomerDateOfBirth); + const email = useAppSelector(getMyCustomerEmail); + const addresses = useAppSelector(getMyCustomerAddresses); + const shippingAddressId = useAppSelector(getMyCustomerShippingAddressIds); + const billingAddressId = useAppSelector(getMyCustomerBillingAddressIds); + const shippingDefaultAddressId = useAppSelector(getMyCustomerDefaultShippingAddressId); + const billingDefaultAddressId = useAppSelector(getMyCustomerDefaultBillingAddressId); + const password = useAppSelector(getMyCustomerPassword); + + const [activeStep, setActiveStep] = useState(0); + const [skipped, setSkipped] = useState(new Set()); + const { errors: validationErrors, validateField } = useValidate(); + + const { + register, + handleSubmit, + formState, + getValues, + setValue, + setError, + clearErrors, + reset: resetForm, + } = useForm(); + + const isStepSkipped = (step: number) => { + return skipped.has(step); + }; + + const handleNext = () => { + let newSkipped = skipped; + if (isStepSkipped(activeStep)) { + newSkipped = new Set(newSkipped.values()); + newSkipped.delete(activeStep); + } + + setActiveStep((prevActiveStep) => prevActiveStep + 1); + setSkipped(newSkipped); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + const handleReset = () => { + setActiveStep(0); + }; + + const globalErrors = Object.keys(validationErrors).reduce>( + (acc, item) => { + if (validationErrors[item as keyof IRegistrationFormData]) { + acc[item as keyof IRegistrationFormData] = { + message: validationErrors[item as keyof IRegistrationFormData], + }; + } + + if ( + formState.errors[item as keyof FieldErrors] && + !validationErrors[item as keyof IRegistrationFormData] + ) { + acc[item as keyof IRegistrationFormData] = { + message: + formState.errors[item as keyof FieldErrors]?.message ?? null, + }; + } + + return acc; + }, + {}, + ); + + console.log('globalErrors', globalErrors); + + const validationHandler = (fieldName: fieldNameType, value: string, values?: IValues): void => { + if (!value) { + const updatedErrors = { + ...validationErrors, + [fieldName]: '', + }; + + clearErrors(fieldName); + Object.assign(validationErrors, updatedErrors); + return; + } + + const errString = validateField(fieldName, value, values); + + if (errString.length) { + setError(fieldName, { + type: 'required', + message: errString, + }); + } else { + clearErrors(fieldName); + } + }; + + const onSubmit: SubmitHandler = (data) => { + if (!Object.keys(globalErrors).length) { + console.log('data', data); + } + }; + + const buttonSubmitClick = () => { + console.log('button submit click'); + }; + + const userData = [firstName, lastName, birthDate, email]; + + const userAddresses = [ + addresses, + shippingAddressId, + billingAddressId, + shippingDefaultAddressId, + billingDefaultAddressId, + ]; + + return ( + + + + + + {steps.map((label, index) => { + const stepProps: { completed?: boolean } = {}; + + if (isStepSkipped(index)) { + stepProps.completed = false; + } + return ( + + {label} + + ); + })} + + {activeStep === steps.length ? ( + + + All pages completed - you're updated + + + + + + + ) : ( + + + Profile Page {activeStep + 1} + + + {!!Object.keys(formState.errors).length && ( + + + Please review and ensure all fields are correctly filled! + + + )} + {activeStep + 1 === 1 && ( + + )} + {activeStep + 1 === 2 && ( + + )} + {activeStep + 1 === 3 && ( + + )} + + + + + + + + + + + )} + + + + ); }; diff --git a/e-commerce-app/src/pages/UserPage/UserPassword.tsx b/e-commerce-app/src/pages/UserPage/UserPassword.tsx new file mode 100644 index 0000000..2c6cca6 --- /dev/null +++ b/e-commerce-app/src/pages/UserPage/UserPassword.tsx @@ -0,0 +1,74 @@ +import { FC } from 'react'; +import TextField from '@mui/material/TextField'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import PageImg from '../../assets/images/UserPageImg.png'; +import { IUserProps } from '../../interfaces/IUserProps'; + +export const UserPassword: FC = ({ + register, + validationHandler, + errors, + setValue, + password, + getValues, +}) => { + console.log('password', password); + + const values = { + password: getValues?.('password') ?? '', + confirmPassword: getValues?.('confirmPassword') ?? '', + countryShipping: getValues?.('countryShipping') ?? '', + countryBilling: getValues?.('countryBilling') ?? '', + }; + + return ( + + + Image1 + + + { + return value === password || 'Current Password does not match'; + }, + })} + /> + + validationHandler('password', e.target.value, values), + })} + /> + + + validationHandler('confirmPassword', e.target.value, values), + })} + /> + + + + ); +}; diff --git a/e-commerce-app/src/requestsComponents/CategoriesQuery/CategoriesQuery.tsx b/e-commerce-app/src/requestsComponents/CategoriesQuery/CategoriesQuery.tsx new file mode 100644 index 0000000..19d6bff --- /dev/null +++ b/e-commerce-app/src/requestsComponents/CategoriesQuery/CategoriesQuery.tsx @@ -0,0 +1,59 @@ +import React, { JSX, useEffect, useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { getAccessToken } from '../../store/slices/userSlice'; +import LoadingProgress from '../../components/LoadingProgress/LoadingProgress'; +import { Outlet } from 'react-router-dom'; +import { useGetAllCategoriesQuery } from '../../api/categoriesApi'; +import { setCategories, startLoadingCategories } from '../../store/slices/categoriesSlice'; +import { useGetAllTaxesQuery } from '../../api/taxApi'; +import { setTaxes, startLoadingTaxes } from '../../store/slices/taxesSlice'; + +const CategoriesQuery = (): JSX.Element => { + const accessToken = useAppSelector(getAccessToken); + const dispatch = useAppDispatch(); + const [waitGuard, setWaitGuard] = useState(false); + + const { + isLoading: isLoadingTaxes, + isSuccess: isSuccessTaxes, + isFetching: isFetchingTaxes, + data: dataTaxes, + } = useGetAllTaxesQuery(accessToken as string); + + const { + isLoading: isLoadingCategories, + isSuccess: isSuccessCategories, + isFetching: isFetchingCategories, + data: dataCategories, + } = useGetAllCategoriesQuery(accessToken as string); + + useEffect(() => { + if (isFetchingCategories) { + dispatch(startLoadingCategories()); + } + if (isFetchingTaxes) { + dispatch(startLoadingTaxes()); + } + }, [isFetchingCategories, isFetchingTaxes, isLoadingCategories, isLoadingTaxes]); + + useEffect(() => { + if (dataCategories && dataTaxes) { + setWaitGuard(false); + } else { + setWaitGuard(true); + } + if (dataCategories && 'results' in dataCategories) { + dispatch(setCategories(dataCategories)); + } + if (dataTaxes && 'results' in dataTaxes) { + dispatch(setTaxes(dataTaxes)); + } + }, [isSuccessCategories, dataCategories, isSuccessTaxes, dataTaxes]); + + if (waitGuard) { + return ; + } + + return ; +}; +export default CategoriesQuery; diff --git a/e-commerce-app/src/requestsComponents/ProductsQuery/ProductsQuery.tsx b/e-commerce-app/src/requestsComponents/ProductsQuery/ProductsQuery.tsx new file mode 100644 index 0000000..35d0b4a --- /dev/null +++ b/e-commerce-app/src/requestsComponents/ProductsQuery/ProductsQuery.tsx @@ -0,0 +1,165 @@ +import React, { JSX, useEffect, useState } from 'react'; +import { useGetAllProductsMutation } from '../../api/productsApi'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { getAccessToken } from '../../store/slices/userSlice'; +import LoadingProgress from '../../components/LoadingProgress/LoadingProgress'; +import { ProductsPage } from '../../pages/ProductsPage/ProductsPage'; +import { setProducts, startLoadingProducts } from '../../store/slices/productsSlice'; +import { + getQueryCategories, + getQueryCentAmount, + getQuerySort, + getQueryText, +} from '../../store/slices/queryParamsSlice'; +import { IBaseQueryParams } from '../../types/slicesTypes/baseApiRequestsTypes'; +import { useSearchProductsMutation } from '../../api/productProjectionApi'; +import { makeProductSliceObjectFromSearchApiRequest } from '../../utils/makeProductSliceObjectFromSearchApiRequest'; +import { makeGetQueryProductsString } from '../../utils/makeGetQueryProductsString'; + +const ProductsQuery = (): JSX.Element => { + const dispatch = useAppDispatch(); + const accessToken = useAppSelector(getAccessToken); + const searchQuerySort = useAppSelector(getQuerySort); + const searchQueryText = useAppSelector(getQueryText); + const searchQueryCentAmount = useAppSelector(getQueryCentAmount); + const searchQueryCategories = useAppSelector(getQueryCategories); + + const [params, setParams] = useState({}); + + useEffect(() => { + if (searchQuerySort) { + setParams((prevState) => ({ + ...prevState, + sort: searchQuerySort, + })); + } else { + setParams((prevState) => { + const newState = prevState; + delete newState.sort; + return newState; + }); + } + }, [searchQuerySort]); + + useEffect(() => { + if (searchQueryText) { + setParams((prevState) => ({ + ...prevState, + ['text.en']: searchQueryText, + fuzzy: searchQueryText.length > 2, + fuzzyLevel: searchQueryText.length <= 2 ? 0 : searchQueryText.length > 4 ? 2 : 1, + })); + } else { + setParams((prevState) => { + const newObj = { + ...prevState, + }; + delete newObj['text.en']; + delete newObj.fuzzyLevel; + delete newObj.fuzzy; + return newObj; + }); + } + }, [searchQueryText]); + + useEffect(() => { + const filterArr: string[] = []; + + if (!(searchQueryCentAmount[0] === 0 && searchQueryCentAmount[1] === 100)) { + filterArr.push( + `variants.price.centAmount:range+(${searchQueryCentAmount[0] * 100}+to+${ + searchQueryCentAmount[1] * 100 + })`, + ); + } + if (searchQueryCategories) { + filterArr.push(`categories.id:"${searchQueryCategories}"`); + } + + if (filterArr.length === 0) { + setParams((prevState) => { + const newState = { + ...prevState, + }; + delete newState.filter; + return newState; + }); + } + setParams((prevState) => ({ + ...prevState, + filter: filterArr, + })); + }, [searchQueryCentAmount, searchQueryCategories]); + + const [ + getAllProducts, + { + isLoading: isLoadingProducts, + isSuccess: isSuccessProducts, + isError: isErrorProducts, + data: dataProducts, + }, + ] = useGetAllProductsMutation(); + const [ + searchProducts, + { + isLoading: isLoadingSearch, + isSuccess: isSuccessSearch, + isError: isErrorSearch, + data: dataSearch, + }, + ] = useSearchProductsMutation(); + + useEffect(() => { + if ( + (params['text.en']?.length && params['text.en']?.length > 0) || + params.sort || + (params.filter && params.filter.length > 0) + ) { + const resultPath = makeGetQueryProductsString(params); + searchProducts({ + token: accessToken as string, + params: { + resultPath, + }, + }); + } else { + getAllProducts({ + token: accessToken as string, + params, + }); + } + }, [params]); + + useEffect(() => { + if (isLoadingProducts || isLoadingSearch) { + dispatch(startLoadingProducts()); + } + }, [isLoadingProducts, isLoadingSearch]); + + useEffect(() => { + if ( + (isSuccessSearch && params['text.en']) || + searchQuerySort || + (params.filter && params.filter.length > 0) + ) { + if (dataSearch && 'results' in dataSearch) { + const pushingObject = makeProductSliceObjectFromSearchApiRequest(dataSearch); + dispatch(setProducts(pushingObject)); + } + return; + } + if (isSuccessProducts) { + if (dataProducts && 'results' in dataProducts) { + dispatch(setProducts(dataProducts)); + } + } + }, [isSuccessProducts, dataProducts, isSuccessSearch, dataSearch]); + + if (isLoadingProducts || isErrorProducts || isLoadingSearch || isErrorSearch) { + return ; + } + + return ; +}; +export default ProductsQuery; diff --git a/e-commerce-app/src/requestsComponents/TokenGuard/TokenGuard.tsx b/e-commerce-app/src/requestsComponents/TokenGuard/TokenGuard.tsx new file mode 100644 index 0000000..9c398fe --- /dev/null +++ b/e-commerce-app/src/requestsComponents/TokenGuard/TokenGuard.tsx @@ -0,0 +1,16 @@ +import React, { JSX } from 'react'; +import { useAppSelector } from '../../store/hooks'; +import { getAccessToken } from '../../store/slices/userSlice'; +import LoadingProgress from '../../components/LoadingProgress/LoadingProgress'; +import { Outlet } from 'react-router-dom'; + +const TokenGuard = (): JSX.Element => { + const accessToken = useAppSelector(getAccessToken); + + if (!accessToken) { + return ; + } + + return ; +}; +export default TokenGuard; diff --git a/e-commerce-app/src/requestsComponents/UserQuery/UserQuery.tsx b/e-commerce-app/src/requestsComponents/UserQuery/UserQuery.tsx new file mode 100644 index 0000000..c0bf5f9 --- /dev/null +++ b/e-commerce-app/src/requestsComponents/UserQuery/UserQuery.tsx @@ -0,0 +1,34 @@ +import { JSX, useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { getAccessToken } from '../../store/slices/userSlice'; +import { Outlet } from 'react-router-dom'; +import { useGetMyCustomerDetailsMutation } from '../../api/myCustomerApi'; +import LoadingProgress from '../../components/LoadingProgress/LoadingProgress'; +import { clearMyCustomerData, setMyCustomerData } from '../../store/slices/myCustomerSlice'; + +const UserQuery = (): JSX.Element => { + const dispatch = useAppDispatch(); + const accessToken = useAppSelector(getAccessToken); + + const [getMyCustomerDetails, { data, isSuccess, isLoading }] = useGetMyCustomerDetailsMutation(); + + useEffect(() => { + dispatch(clearMyCustomerData()); + if (accessToken) { + getMyCustomerDetails(accessToken); + } + }, []); + + useEffect(() => { + if (!isSuccess) return; + if (data) { + dispatch(setMyCustomerData(data)); + } + }, [isSuccess, data]); + + if (isLoading) { + return ; + } + return ; +}; +export default UserQuery; diff --git a/e-commerce-app/src/routes/AppRoutes.tsx b/e-commerce-app/src/routes/AppRoutes.tsx index aa532cd..5e3b7d6 100644 --- a/e-commerce-app/src/routes/AppRoutes.tsx +++ b/e-commerce-app/src/routes/AppRoutes.tsx @@ -12,9 +12,14 @@ import { LoginPage } from '../pages/LoginPage/LoginPage'; import { RegistrationPage } from '../pages/RegistrationPage/RegistrationPage'; import { UserPage } from '../pages/UserPage/UserPage'; import { ProductPage } from '../pages/ProductPage/ProductPage'; -import { ProductsPage } from '../pages/ProductsPage/ProductsPage'; import { ErrorPage } from '../pages/ErrorPage/ErrorPage'; import { LogoutPage } from '../pages/LogoutPage/LogoutPage'; +import { PrivateRoute } from './PrivateRoute'; +import ProductsQuery from '../requestsComponents/ProductsQuery/ProductsQuery'; +import CategoriesQuery from '../requestsComponents/CategoriesQuery/CategoriesQuery'; +import TokenGuard from '../requestsComponents/TokenGuard/TokenGuard'; +import UserQuery from '../requestsComponents/UserQuery/UserQuery'; +import UserRedirect from '../components/UserRedirect/UserRedirect'; const router = createHashRouter( createRoutesFromElements( @@ -22,13 +27,23 @@ const router = createHashRouter( }> } /> } /> - } /> } /> } /> - } /> - } /> - } /> - } /> + + }> + } /> + } /> + }> + }> + }/> + } /> + + + }> + } /> + } /> + + } /> , diff --git a/e-commerce-app/src/routes/PrivateRoute.tsx b/e-commerce-app/src/routes/PrivateRoute.tsx new file mode 100644 index 0000000..cbadbe1 --- /dev/null +++ b/e-commerce-app/src/routes/PrivateRoute.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { getLoggedIn } from '../store/slices/userSlice'; +import { useAppSelector } from '../store/hooks'; +import { Outlet, Navigate } from 'react-router-dom'; + +export const PrivateRoute: FC = () => { + const isLoggedIn = useAppSelector(getLoggedIn); + if (isLoggedIn) { + return ; + } else { + return ; + } +}; + +export default PrivateRoute; diff --git a/e-commerce-app/src/routes/navigation.ts b/e-commerce-app/src/routes/navigation.ts index 0a6e5e1..cb6e003 100644 --- a/e-commerce-app/src/routes/navigation.ts +++ b/e-commerce-app/src/routes/navigation.ts @@ -5,18 +5,18 @@ export const userLoginRoutes = { export const userLogoutRoutes = { logout: '/logout', + user: '/user', }; export const navigationRoutes = { home: '/', - ...userLoginRoutes, - ...userLogoutRoutes, + products: '/products', + user: '/user', }; export const unusedNavigation = { about: '/about', basket: '/basket', - user: '/user', product: '/product', products: '/products', error: '*', diff --git a/e-commerce-app/src/store/slices/categoriesSlice.ts b/e-commerce-app/src/store/slices/categoriesSlice.ts new file mode 100644 index 0000000..90cbf8b --- /dev/null +++ b/e-commerce-app/src/store/slices/categoriesSlice.ts @@ -0,0 +1,50 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootStateType } from '../store'; +import { ICategoriesFromSlice } from '../../types/slicesTypes/categoriesSliceTypes'; +import { IGetAllCategoriesResponse } from '../../types/slicesTypes/categoriesApiTypes'; + +const initialState: ICategoriesFromSlice = { + fetching: false, + categories: [], + total: 0, + limit: 0, + offset: 0, + count: 0, +}; + +export const categoriesSlice = createSlice({ + initialState, + name: 'categoriesSlice', + reducers: { + startLoadingCategories: (state) => { + state.count = 0; + state.categories.length = 0; + state.fetching = true; + }, + setLimit: (state, action: PayloadAction) => { + state.limit = action.payload; + }, + setOffset: (state, action: PayloadAction) => { + state.offset = action.payload; + }, + setCount: (state, action: PayloadAction) => { + state.count = action.payload; + }, + setTotal: (state, action: PayloadAction) => { + state.total = action.payload; + }, + setCategories: (state, action: PayloadAction) => { + state.total = action.payload.total; + state.count = action.payload.count; + state.offset = action.payload.offset; + state.limit = action.payload.limit; + state.categories = action.payload.results; + state.fetching = false; + }, + }, +}); + +export const CategoriesReducer = categoriesSlice.reducer; +export const isFetchingCategories = (state: RootStateType) => state.categories.fetching; +export const getAllCategories = (state: RootStateType) => state.categories.categories; +export const { startLoadingCategories, setCategories } = categoriesSlice.actions; diff --git a/e-commerce-app/src/store/slices/myCustomerSlice.ts b/e-commerce-app/src/store/slices/myCustomerSlice.ts new file mode 100644 index 0000000..fe88b2e --- /dev/null +++ b/e-commerce-app/src/store/slices/myCustomerSlice.ts @@ -0,0 +1,83 @@ +import { IMyCustomerBaseResponse } from '../../types/slicesTypes/myCustomerApiSliceTypes'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootStateType } from '../store'; + +const initialState: IMyCustomerBaseResponse = { + addresses: [], + authenticationMode: 'Password', + billingAddressIds: [], + dateOfBirth: '', + email: '', + firstName: '', + id: '', + isEmailVerified: false, + lastName: '', + password: '', + shippingAddressIds: [], +}; + +const myCustomerSlice = createSlice({ + initialState, + name: 'myCustomerSlice', + reducers: { + clearMyCustomerData: (state) => { + state.addresses.length = 0; + state.authenticationMode = 'Password'; + state.billingAddressIds.length = 0; + state.dateOfBirth = ''; + state.email = ''; + state.firstName = ''; + state.id = ''; + state.isEmailVerified = false; + state.lastName = ''; + state.password = ''; + state.shippingAddressIds.length = 0; + if (state.defaultBillingAddressId) { + delete state.defaultBillingAddressId; + } + if (state.defaultShippingAddressId) { + delete state.defaultShippingAddressId; + } + }, + setMyCustomerData: (state, action: PayloadAction) => { + state.addresses = action.payload.addresses; + state.authenticationMode = action.payload.authenticationMode; + state.billingAddressIds = action.payload.billingAddressIds; + state.dateOfBirth = action.payload.dateOfBirth; + state.email = action.payload.email; + state.firstName = action.payload.firstName; + state.id = action.payload.id; + state.isEmailVerified = action.payload.isEmailVerified; + state.lastName = action.payload.lastName; + state.password = action.payload.password; + state.shippingAddressIds = action.payload.shippingAddressIds; + if (action.payload.defaultBillingAddressId) { + state.defaultBillingAddressId = action.payload.defaultBillingAddressId; + } + if (action.payload.defaultShippingAddressId) { + state.defaultShippingAddressId = action.payload.defaultShippingAddressId; + } + }, + }, +}); + +export const getMyCustomerAddresses = (state: RootStateType) => state.myCustomer.addresses; +export const getMyCustomerBillingAddressIds = (state: RootStateType) => + state.myCustomer.billingAddressIds; +export const getMyCustomerDateOfBirth = (state: RootStateType) => state.myCustomer.dateOfBirth; +export const getMyCustomerEmail = (state: RootStateType) => state.myCustomer.email; +export const getMyCustomerFirstName = (state: RootStateType) => state.myCustomer.firstName; +export const getMyCustomerId = (state: RootStateType) => state.myCustomer.id; +export const getMyCustomerIsEmailVerified = (state: RootStateType) => + state.myCustomer.isEmailVerified; +export const getMyCustomerLastName = (state: RootStateType) => state.myCustomer.lastName; +export const getMyCustomerPassword = (state: RootStateType) => state.myCustomer.password; +export const getMyCustomerShippingAddressIds = (state: RootStateType) => + state.myCustomer.shippingAddressIds; +export const getMyCustomerDefaultBillingAddressId = (state: RootStateType) => + state.myCustomer.defaultBillingAddressId; +export const getMyCustomerDefaultShippingAddressId = (state: RootStateType) => + state.myCustomer.defaultShippingAddressId; + +export const MyCustomerReducer = myCustomerSlice.reducer; +export const { clearMyCustomerData, setMyCustomerData } = myCustomerSlice.actions; diff --git a/e-commerce-app/src/store/slices/productsSlice.ts b/e-commerce-app/src/store/slices/productsSlice.ts new file mode 100644 index 0000000..e3cbaf2 --- /dev/null +++ b/e-commerce-app/src/store/slices/productsSlice.ts @@ -0,0 +1,44 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { IProductsFromSlice } from '../../types/slicesTypes/productsSliceTypes'; +import { IGetAllProductsResponse } from '../../types/slicesTypes/productsApiTypes'; +import { RootStateType } from '../store'; + +const initialState: IProductsFromSlice = { + fetching: false, + products: [], + total: 0, + limit: 0, + offset: 0, + count: 0, +}; + +export const productsSlice = createSlice({ + initialState, + name: 'productsSlice', + reducers: { + startLoadingProducts: (state) => { + state.fetching = true; + }, + setProducts: (state, action: PayloadAction) => { + state.total = action.payload.total; + state.count = action.payload.count; + state.offset = action.payload.offset; + state.limit = action.payload.limit; + state.products = action.payload.results; + state.fetching = false; + }, + resetProducts: (state) => { + state.fetching = false; + state.products = []; + state.total = 0; + state.limit = 0; + state.offset = 0; + state.count = 0; + }, + }, +}); + +export const getProducts = (state: RootStateType) => state.products.products; +export const isFetchingProducts = (state: RootStateType) => state.products.fetching; +export const ProductsReducer = productsSlice.reducer; +export const { startLoadingProducts, setProducts, resetProducts } = productsSlice.actions; diff --git a/e-commerce-app/src/store/slices/queryParamsSlice.ts b/e-commerce-app/src/store/slices/queryParamsSlice.ts new file mode 100644 index 0000000..d0ed2eb --- /dev/null +++ b/e-commerce-app/src/store/slices/queryParamsSlice.ts @@ -0,0 +1,56 @@ +import { IQueryParamsFromSlice } from '../../types/slicesTypes/queryParamsSliceTypes'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootStateType } from '../store'; +import { SortFormType } from '../../types/searchProductsTypes/filterFormTypes'; + +const initialState: IQueryParamsFromSlice = { + limit: 500, + offset: 0, + sort: '', + text: '', + centAmount: [0, 100], + categories: '', +}; + +export const queryParamsSlice = createSlice({ + initialState, + name: 'queryParamsSlice', + reducers: { + setQueryOffset: (state, action: PayloadAction) => { + state.offset = action.payload; + }, + setQuerySort: (state, action: PayloadAction) => { + state.sort = action.payload; + }, + setQueryText: (state, action: PayloadAction) => { + state.text = action.payload; + }, + setEmptySort: (state) => { + state.sort = ''; + state.categories = ''; + state.centAmount = [0, 100]; + }, + setQueryCentAmount: (state, action: PayloadAction) => { + state.centAmount = [...action.payload]; + }, + setQueryCategories: (state, action: PayloadAction) => { + state.categories = action.payload; + }, + }, +}); + +export const getQueryOffset = (state: RootStateType) => state.queryParams.offset; +export const getQueryLimit = (state: RootStateType) => state.queryParams.limit; +export const getQuerySort = (state: RootStateType) => state.queryParams.sort; +export const getQueryText = (state: RootStateType) => state.queryParams.text; +export const getQueryCentAmount = (state: RootStateType) => state.queryParams.centAmount; +export const getQueryCategories = (state: RootStateType) => state.queryParams.categories; +export const QueryParamsReducer = queryParamsSlice.reducer; +export const { + setQueryOffset, + setQuerySort, + setQueryText, + setEmptySort, + setQueryCentAmount, + setQueryCategories, +} = queryParamsSlice.actions; diff --git a/e-commerce-app/src/store/slices/taxesSlice.ts b/e-commerce-app/src/store/slices/taxesSlice.ts new file mode 100644 index 0000000..3b17d8c --- /dev/null +++ b/e-commerce-app/src/store/slices/taxesSlice.ts @@ -0,0 +1,35 @@ +import { ITaxFromSlice } from '../../types/slicesTypes/taxSliceTypes'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { IGetAllTaxesResponse } from '../../types/slicesTypes/taxApiTypes'; +import { RootStateType } from '../store'; + +const initialState: ITaxFromSlice = { + fetching: false, + limit: 0, + count: 0, + offset: 0, + total: 0, + taxes: [], +}; + +export const taxesSlice = createSlice({ + initialState, + name: 'taxesSlice', + reducers: { + startLoadingTaxes: (state) => { + state.fetching = true; + }, + setTaxes: (state, action: PayloadAction) => { + state.total = action.payload.total; + state.count = action.payload.count; + state.offset = action.payload.offset; + state.limit = action.payload.limit; + state.taxes = action.payload.results; + state.fetching = false; + }, + }, +}); + +export const getTaxes = (state: RootStateType) => state.taxes.taxes; +export const TaxesReducer = taxesSlice.reducer; +export const { startLoadingTaxes, setTaxes } = taxesSlice.actions; diff --git a/e-commerce-app/src/store/store.ts b/e-commerce-app/src/store/store.ts index 1e0b2ba..11c3f9c 100644 --- a/e-commerce-app/src/store/store.ts +++ b/e-commerce-app/src/store/store.ts @@ -2,15 +2,40 @@ import { configureStore } from '@reduxjs/toolkit'; import { authApi } from '../api/authApi'; import { UserReducer } from './slices/userSlice'; import { myCustomerApi } from '../api/myCustomerApi'; +import { productsApi } from '../api/productsApi'; +import { categoriesApi } from '../api/categoriesApi'; +import { CategoriesReducer } from './slices/categoriesSlice'; +import { ProductsReducer } from './slices/productsSlice'; +import { productProjectionApi } from '../api/productProjectionApi'; +import { QueryParamsReducer } from './slices/queryParamsSlice'; +import { taxApi } from '../api/taxApi'; +import { TaxesReducer } from './slices/taxesSlice'; +import { MyCustomerReducer } from './slices/myCustomerSlice'; export const store = configureStore({ reducer: { [authApi.reducerPath]: authApi.reducer, [myCustomerApi.reducerPath]: myCustomerApi.reducer, + [productsApi.reducerPath]: productsApi.reducer, + [categoriesApi.reducerPath]: categoriesApi.reducer, + [taxApi.reducerPath]: taxApi.reducer, + [productProjectionApi.reducerPath]: productProjectionApi.reducer, user: UserReducer, + categories: CategoriesReducer, + products: ProductsReducer, + queryParams: QueryParamsReducer, + taxes: TaxesReducer, + myCustomer: MyCustomerReducer, }, middleware: (getDefaultMiddleware) => - getDefaultMiddleware({}).concat([authApi.middleware, myCustomerApi.middleware]), + getDefaultMiddleware({}).concat([ + authApi.middleware, + myCustomerApi.middleware, + productsApi.middleware, + categoriesApi.middleware, + productProjectionApi.middleware, + taxApi.middleware, + ]), }); export type RootStateType = ReturnType; diff --git a/e-commerce-app/src/types/addressesTypes.ts b/e-commerce-app/src/types/addressesTypes.ts index f1d9c70..436947d 100644 --- a/e-commerce-app/src/types/addressesTypes.ts +++ b/e-commerce-app/src/types/addressesTypes.ts @@ -8,3 +8,5 @@ export interface IMyCustomerApiAddressRequest { export interface IMyCustomerAddressResponse extends IMyCustomerApiAddressRequest { id: string; } + +export type CountriesType = 'US' | 'DE' | 'CA' | 'PL' | string; diff --git a/e-commerce-app/src/types/declaration.d.ts b/e-commerce-app/src/types/declaration.d.ts new file mode 100644 index 0000000..d5cf927 --- /dev/null +++ b/e-commerce-app/src/types/declaration.d.ts @@ -0,0 +1 @@ +declare module '*.scss'; diff --git a/e-commerce-app/src/types/fieldNameType.ts b/e-commerce-app/src/types/fieldNameType.ts index 32942e7..c9bc7a1 100644 --- a/e-commerce-app/src/types/fieldNameType.ts +++ b/e-commerce-app/src/types/fieldNameType.ts @@ -2,6 +2,7 @@ export type fieldNameType = | 'email' | 'password' | 'confirmPassword' + | 'currentPassword' | 'lastName' | 'firstName' | 'birthDate' diff --git a/e-commerce-app/src/types/searchProductsTypes/filterFormTypes.ts b/e-commerce-app/src/types/searchProductsTypes/filterFormTypes.ts new file mode 100644 index 0000000..932060f --- /dev/null +++ b/e-commerce-app/src/types/searchProductsTypes/filterFormTypes.ts @@ -0,0 +1,10 @@ +export interface ISortByForm { + sort: SortFormType; + categories: string; +} + +export type SortFormType = '' | SortFormPrice | SortFormName; + +export type SortFormPrice = 'price asc' | 'price desc'; + +export type SortFormName = 'name.en asc' | 'name.en desc'; diff --git a/e-commerce-app/src/types/searchProductsTypes/searchFormTypes.ts b/e-commerce-app/src/types/searchProductsTypes/searchFormTypes.ts new file mode 100644 index 0000000..0397778 --- /dev/null +++ b/e-commerce-app/src/types/searchProductsTypes/searchFormTypes.ts @@ -0,0 +1,3 @@ +export interface ISearchProductForm { + query: string; +} diff --git a/e-commerce-app/src/types/slicesTypes/baseApiRequestsTypes.ts b/e-commerce-app/src/types/slicesTypes/baseApiRequestsTypes.ts new file mode 100644 index 0000000..f04e028 --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/baseApiRequestsTypes.ts @@ -0,0 +1,13 @@ +import { SortFormType } from '../searchProductsTypes/filterFormTypes'; + +export interface IBaseQueryParams { + limit?: number; + offset?: number; + ['text.en']?: string; + ['name.en']?: string; + fuzzy?: boolean; + fuzzyLevel?: 0 | 1 | 2; + sort?: SortFormType | string; + resultPath?: string; + filter?: string[]; +} diff --git a/e-commerce-app/src/types/slicesTypes/baseApiResponsesTypes.ts b/e-commerce-app/src/types/slicesTypes/baseApiResponsesTypes.ts new file mode 100644 index 0000000..1d5f5ca --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/baseApiResponsesTypes.ts @@ -0,0 +1,16 @@ +export interface IBaseGetAllQueryResponse { + limit: number; + offset: number; + count: number; + total: number; + results: Array; +} + +export interface ICategoryTypeResponse { + id: string; + typeId: string; +} + +export interface IMetaDescriptionProductResponse { + en: string; +} diff --git a/e-commerce-app/src/types/slicesTypes/categoriesApiTypes.ts b/e-commerce-app/src/types/slicesTypes/categoriesApiTypes.ts new file mode 100644 index 0000000..cdffbef --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/categoriesApiTypes.ts @@ -0,0 +1,21 @@ +import { IBaseGetAllQueryResponse, ICategoryTypeResponse } from './baseApiResponsesTypes'; + +export interface ICategoryName { + en: string; +} + +export interface ICategoryApiResponse { + ancestors: ICategoryTypeResponse[]; + assets: []; + id: string; + key: string; + name: ICategoryName; + orderHint: string; + parent?: ICategoryTypeResponse; + slug: ICategoryName; + version: number; +} + +export interface IGetAllCategoriesResponse extends IBaseGetAllQueryResponse { + results: ICategoryApiResponse[]; +} diff --git a/e-commerce-app/src/types/slicesTypes/categoriesSliceTypes.ts b/e-commerce-app/src/types/slicesTypes/categoriesSliceTypes.ts new file mode 100644 index 0000000..5e1dd3a --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/categoriesSliceTypes.ts @@ -0,0 +1,10 @@ +import { ICategoryApiResponse } from './categoriesApiTypes'; + +export interface ICategoriesFromSlice { + fetching: boolean; + categories: ICategoryApiResponse[]; + total: number; + limit: number; + offset: number; + count: number; +} diff --git a/e-commerce-app/src/types/slicesTypes/myCustomerApiSliceTypes.ts b/e-commerce-app/src/types/slicesTypes/myCustomerApiSliceTypes.ts index 2b6d5ea..f17b0be 100644 --- a/e-commerce-app/src/types/slicesTypes/myCustomerApiSliceTypes.ts +++ b/e-commerce-app/src/types/slicesTypes/myCustomerApiSliceTypes.ts @@ -8,19 +8,19 @@ export interface IMyCustomerLoginRequest { } export interface IMyCustomerBaseResponse { - id: string; + addresses: IMyCustomerApiAddressRequest[]; + authenticationMode: 'Password' | string; + billingAddressIds: string[]; + dateOfBirth: string; email: string; firstName: string; + id: string; + isEmailVerified: boolean; lastName: string; - dateOfBirth: string; password: string; - addresses: IMyCustomerApiAddressRequest[]; - billingAddressIds: string[]; shippingAddressIds: string[]; defaultBillingAddressId?: string; defaultShippingAddressId?: string; - isEmailVerified: boolean; - authenticationMode: 'Password' | string; } export interface IMyCustomerApiSignupRequest { diff --git a/e-commerce-app/src/types/slicesTypes/productProjectionsApiTypes.ts b/e-commerce-app/src/types/slicesTypes/productProjectionsApiTypes.ts new file mode 100644 index 0000000..577640b --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/productProjectionsApiTypes.ts @@ -0,0 +1,13 @@ +import { IBaseGetAllQueryResponse, ICategoryTypeResponse } from './baseApiResponsesTypes'; +import { IProductApiDescriptionResponse } from './productsApiTypes'; + +export interface ISearchApiResponse extends IProductApiDescriptionResponse { + id: string; + key: string; + productType: ICategoryTypeResponse; + taxCategory: ICategoryTypeResponse; +} + +export interface ISearchProductsResponse extends IBaseGetAllQueryResponse { + results: ISearchApiResponse[]; +} diff --git a/e-commerce-app/src/types/slicesTypes/productsApiTypes.ts b/e-commerce-app/src/types/slicesTypes/productsApiTypes.ts new file mode 100644 index 0000000..6ebce6c --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/productsApiTypes.ts @@ -0,0 +1,83 @@ +import { + IBaseGetAllQueryResponse, + ICategoryTypeResponse, + IMetaDescriptionProductResponse, +} from './baseApiResponsesTypes'; +import { IBaseQueryParams } from './baseApiRequestsTypes'; + +export interface IAttributeProductApiResponse { + name: string; + value: string; +} + +export interface IImageProductApiResponse { + url: string; + dimensions: { + w: number; + h: number; + }; +} + +export interface IValuePriceProductApiResponse { + type: string; + currencyCode: string; + centAmount: number; + fractionDigits: number; + country: string; + channel: ICategoryTypeResponse; +} + +export interface IPriceProductApiResponse { + id: string; + value: IValuePriceProductApiResponse; +} + +export interface IMasterVariantProductApiResponse { + assets: []; + attributes: IAttributeProductApiResponse[]; + id: number; + images: IImageProductApiResponse[]; + key: string; + prices: IPriceProductApiResponse[]; + sku: string; +} + +export interface IProductApiDescriptionResponse { + categories: ICategoryTypeResponse[]; + categoryOrderHints: object; + masterVariant: IMasterVariantProductApiResponse; + metaDescription: IMetaDescriptionProductResponse; + name: IMetaDescriptionProductResponse; + searchKeywords: object; + slug: IMetaDescriptionProductResponse; + variants: []; +} + +export interface IMasterDataProductApiResponse { + current: IProductApiDescriptionResponse; + hasStagedChanges: boolean; + published: boolean; + staged: IProductApiDescriptionResponse; +} + +export interface IProductApiResponse { + id: string; + key: string; + masterData: IMasterDataProductApiResponse; + productType: ICategoryTypeResponse; + taxCategory: ICategoryTypeResponse; +} + +export interface IGetAllProductsResponse extends IBaseGetAllQueryResponse { + results: IProductApiResponse[]; +} + +export interface IGetAllProductsRequest { + token: string; + params?: IBaseQueryParams; +} + +export interface IGetProductByIdRequest { + token: string; + productId: string; +} diff --git a/e-commerce-app/src/types/slicesTypes/productsSliceTypes.ts b/e-commerce-app/src/types/slicesTypes/productsSliceTypes.ts new file mode 100644 index 0000000..3db7da7 --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/productsSliceTypes.ts @@ -0,0 +1,10 @@ +import { IProductApiResponse } from './productsApiTypes'; + +export interface IProductsFromSlice { + fetching: boolean; + products: IProductApiResponse[]; + total: number; + limit: number; + offset: number; + count: number; +} diff --git a/e-commerce-app/src/types/slicesTypes/queryParamsSliceTypes.ts b/e-commerce-app/src/types/slicesTypes/queryParamsSliceTypes.ts new file mode 100644 index 0000000..0750362 --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/queryParamsSliceTypes.ts @@ -0,0 +1,10 @@ +import { SortFormType } from '../searchProductsTypes/filterFormTypes'; + +export interface IQueryParamsFromSlice { + limit: number; + offset: number; + text: string; + sort: SortFormType; + centAmount: number[]; + categories: string; +} diff --git a/e-commerce-app/src/types/slicesTypes/taxApiTypes.ts b/e-commerce-app/src/types/slicesTypes/taxApiTypes.ts new file mode 100644 index 0000000..535cbe8 --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/taxApiTypes.ts @@ -0,0 +1,16 @@ +import { CountriesType } from '../addressesTypes'; +import { IBaseGetAllQueryResponse } from './baseApiResponsesTypes'; + +export interface ITaxApiResponse { + amount: number; + country: CountriesType; + id: string; + includedInPrice: boolean; + name: string; + rates: []; + key: string; +} + +export interface IGetAllTaxesResponse extends IBaseGetAllQueryResponse { + results: ITaxApiResponse[]; +} diff --git a/e-commerce-app/src/types/slicesTypes/taxSliceTypes.ts b/e-commerce-app/src/types/slicesTypes/taxSliceTypes.ts new file mode 100644 index 0000000..3505615 --- /dev/null +++ b/e-commerce-app/src/types/slicesTypes/taxSliceTypes.ts @@ -0,0 +1,10 @@ +import { ITaxApiResponse } from './taxApiTypes'; + +export interface ITaxFromSlice { + fetching: boolean; + taxes: ITaxApiResponse[]; + total: number; + limit: number; + offset: number; + count: number; +} diff --git a/e-commerce-app/src/utils/makeGetQueryProductsString.ts b/e-commerce-app/src/utils/makeGetQueryProductsString.ts new file mode 100644 index 0000000..f327012 --- /dev/null +++ b/e-commerce-app/src/utils/makeGetQueryProductsString.ts @@ -0,0 +1,17 @@ +import { IBaseQueryParams } from '../types/slicesTypes/baseApiRequestsTypes'; + +export const makeGetQueryProductsString = (params: IBaseQueryParams): string => { + const out = Object.entries(params) + .reduce((params, [k, v]) => { + if (!v || v.length === 0) return [...params]; + if (Array.isArray(v)) { + const substr = v.join(`&${k}=`); + return [...params, `${k}=${substr}`]; + } + return [...params, `${k}=${v}`]; + }, []) + .join('&') + .replace(/\s/g, '+'); + + return `?${out}`; +}; diff --git a/e-commerce-app/src/utils/makeProductSliceObjectFromSearchApiRequest.ts b/e-commerce-app/src/utils/makeProductSliceObjectFromSearchApiRequest.ts new file mode 100644 index 0000000..b657458 --- /dev/null +++ b/e-commerce-app/src/utils/makeProductSliceObjectFromSearchApiRequest.ts @@ -0,0 +1,51 @@ +import { + ISearchApiResponse, + ISearchProductsResponse, +} from '../types/slicesTypes/productProjectionsApiTypes'; +import { + IGetAllProductsResponse, + IMasterDataProductApiResponse, + IProductApiDescriptionResponse, + IProductApiResponse, +} from '../types/slicesTypes/productsApiTypes'; + +const makeFromSearchApiProductApiResponses = (el: ISearchApiResponse): IProductApiResponse => { + const current: IProductApiDescriptionResponse = { + categories: el.categories, + categoryOrderHints: el.categoryOrderHints, + masterVariant: el.masterVariant, + metaDescription: el.metaDescription, + name: el.name, + searchKeywords: el.searchKeywords, + slug: el.slug, + variants: el.variants, + }; + const masterData: IMasterDataProductApiResponse = { + current: current, + hasStagedChanges: false, + published: true, + staged: current, + }; + const newProduct: IProductApiResponse = { + id: el.id, + key: el.key, + masterData: masterData, + productType: el.productType, + taxCategory: el.taxCategory, + }; + return newProduct; +}; + +export const makeProductSliceObjectFromSearchApiRequest = ( + dataSearch: ISearchProductsResponse, +): IGetAllProductsResponse => { + const productsArray = dataSearch.results.map((el) => makeFromSearchApiProductApiResponses(el)); + const pushingObject: IGetAllProductsResponse = { + limit: dataSearch.limit, + offset: dataSearch.offset, + count: dataSearch.count, + total: dataSearch.total, + results: productsArray, + }; + return pushingObject; +};