From 9eab12acde5a6a6726672c7a0d4214ba4ece061b Mon Sep 17 00:00:00 2001 From: oleast Date: Fri, 12 Jun 2020 14:11:39 +0200 Subject: [PATCH] Begin building webshop --- src/core/appUrls.ts | 2 + src/core/redux/Store.tsx | 6 ++ src/pages/webshop/index.tsx | 9 ++ src/webshop/api/products.ts | 8 ++ .../ProductList/ProductCard/ProductCard.less | 44 +++++++++ .../ProductList/ProductCard/index.tsx | 38 ++++++++ .../ProductList/ProductResults.less | 21 +++++ .../components/ProductList/ProductResults.tsx | 24 +++++ src/webshop/components/ProductList/index.tsx | 22 +++++ src/webshop/models/index.ts | 1 + src/webshop/slices/productCategory.ts | 44 +++++++++ src/webshop/slices/productSize.ts | 42 +++++++++ src/webshop/slices/products.ts | 91 +++++++++++++++++++ 13 files changed, 352 insertions(+) create mode 100644 src/pages/webshop/index.tsx create mode 100644 src/webshop/api/products.ts create mode 100644 src/webshop/components/ProductList/ProductCard/ProductCard.less create mode 100644 src/webshop/components/ProductList/ProductCard/index.tsx create mode 100644 src/webshop/components/ProductList/ProductResults.less create mode 100644 src/webshop/components/ProductList/ProductResults.tsx create mode 100644 src/webshop/components/ProductList/index.tsx create mode 100644 src/webshop/slices/productCategory.ts create mode 100644 src/webshop/slices/productSize.ts create mode 100644 src/webshop/slices/products.ts diff --git a/src/core/appUrls.ts b/src/core/appUrls.ts index 6339ac03..e2473e55 100644 --- a/src/core/appUrls.ts +++ b/src/core/appUrls.ts @@ -32,3 +32,5 @@ export const getProfileStatisticsEventsUrl = () => url`/profile/statistics/event export const getProfileStatisticsOrdersUrl = () => url`/profile/statistics/orders`; export const getProfileSearchUrl = () => url`/profile/search`; export const getResourcesUrl = () => url`/resources`; +export const getWebshopUrl = () => url`/webshop`; +export const getWebshopProductUrl = (productId: number) => url`/webshop/products/${{ productId }}`; diff --git a/src/core/redux/Store.tsx b/src/core/redux/Store.tsx index 5dea5205..045d0d09 100644 --- a/src/core/redux/Store.tsx +++ b/src/core/redux/Store.tsx @@ -18,6 +18,9 @@ import { notificationMessagesReducer } from 'notifications/slices/notifications' import { notificationPermissionsReducer } from 'notifications/slices/permissions'; import { notificationSubscriptionsReducer } from 'notifications/slices/subscriptions'; import { notificationUserPermissionsReducer } from 'notifications/slices/userPermissions'; +import { webshopProductsReducer } from 'webshop/slices/products'; +import { webshopProductSizesReducer } from 'webshop/slices/productSize'; +import { webshopProductCategoriesReducer } from 'webshop/slices/productCategory'; export const initStore = (initialState: {} = {}) => { return configureStore({ @@ -42,6 +45,9 @@ export const initStore = (initialState: {} = {}) => { ruleBundles: ruleBundlesReducer, shop: shopReducer, transactions: transactionsReducer, + webshopProductCategories: webshopProductCategoriesReducer, + webshopProducts: webshopProductsReducer, + webshopProductSizes: webshopProductSizesReducer, }, /* eslint sort-keys: "off" */ }); diff --git a/src/pages/webshop/index.tsx b/src/pages/webshop/index.tsx new file mode 100644 index 00000000..ae216b17 --- /dev/null +++ b/src/pages/webshop/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { ProductList } from 'webshop/components/ProductList'; + +const WebshopIndex = () => { + return ; +}; + +export default WebshopIndex; diff --git a/src/webshop/api/products.ts b/src/webshop/api/products.ts new file mode 100644 index 00000000..10a0cab0 --- /dev/null +++ b/src/webshop/api/products.ts @@ -0,0 +1,8 @@ +import { listResource, retrieveResource } from 'common/resources'; +import { IProduct } from 'webshop/models'; + +const API_URL = '/api/v1/webshop/products'; + +export const listProducts = listResource(API_URL); + +export const retrieveProduct = retrieveResource(API_URL); diff --git a/src/webshop/components/ProductList/ProductCard/ProductCard.less b/src/webshop/components/ProductList/ProductCard/ProductCard.less new file mode 100644 index 00000000..ccefbd9e --- /dev/null +++ b/src/webshop/components/ProductList/ProductCard/ProductCard.less @@ -0,0 +1,44 @@ +@import '~common/less/constants.less'; +@import '~common/less/colors.less'; +@import '~common/less/mixins.less'; + +@padding: 10px; + +.productCard { + .owCard(); + position: relative; + display: flex; + flex-direction: column; + padding: @padding; + transition: border-color 0.25s; + + &:hover { + border-color: @blue; + } +} + +.imageContainer { + display: flex; + justify-content: center; + + img { + width: 100%; + } +} + +.title { + padding: 15px 0; + text-align: center; + border-bottom: 1px solid @lightGray; + margin-bottom: 15px; +} + +.detailsContainer { + margin-top: auto; + text-align: right; +} + +.price { + display: inline-block; + margin-top: 15px; +} diff --git a/src/webshop/components/ProductList/ProductCard/index.tsx b/src/webshop/components/ProductList/ProductCard/index.tsx new file mode 100644 index 00000000..dac59c0b --- /dev/null +++ b/src/webshop/components/ProductList/ProductCard/index.tsx @@ -0,0 +1,38 @@ +import React, { FC } from 'react'; + +import Markdown from 'common/components/Markdown'; +import ResponsiveImage from 'common/components/ResponsiveImage'; +import { getCompanyUrl } from 'core/appUrls'; +import { Link } from 'core/components/Router'; +import { useSelector } from 'core/redux/hooks'; +import { State } from 'core/redux/Store'; +import { productSelectors } from 'webshop/slices/products'; +import { IProduct } from 'webshop/models'; + +import style from './ProductCard.less'; + +interface IProps { + productId: number; +} + +export const ProductCard: FC = ({ productId }) => { + const product = useSelector(selectProductById(productId)); + return ( + + +
+ +
+

{product.name}

+ +
+

Pris: {product.price} kr

+
+
+ + ); +}; + +const selectProductById = (productId: number) => (state: State) => { + return productSelectors.selectById(state, productId) as IProduct; +}; diff --git a/src/webshop/components/ProductList/ProductResults.less b/src/webshop/components/ProductList/ProductResults.less new file mode 100644 index 00000000..ad0513dc --- /dev/null +++ b/src/webshop/components/ProductList/ProductResults.less @@ -0,0 +1,21 @@ +@import '~common/less/constants.less'; +@import '~common/less/colors.less'; +@import '~common/less/mixins.less'; + +@itemMinMax: minmax(min-content, 350px); + +.productsContainer { + width: 100%; + display: grid; + justify-content: center; + grid-template-columns: repeat(3, @itemMinMax); + gap: 24px; + + @media screen and (max-width: @owTabletBreakpoint) { + grid-template-columns: repeat(2, @itemMinMax); + } + + @media screen and (max-width: @owMobileBreakpoint) { + grid-template-columns: repeat(1, @itemMinMax); + } +} diff --git a/src/webshop/components/ProductList/ProductResults.tsx b/src/webshop/components/ProductList/ProductResults.tsx new file mode 100644 index 00000000..3c5f6fc5 --- /dev/null +++ b/src/webshop/components/ProductList/ProductResults.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { shallowEqual } from 'react-redux'; + +import { State } from 'core/redux/Store'; +import { useSelector } from 'core/redux/hooks'; +import { productSelectors } from 'webshop/slices/products'; + +import { ProductCard } from './ProductCard'; +import style from './ProductResults.less'; + +export const ProductResults = () => { + const productIds = useSelector(selectProducts(), shallowEqual); + return ( +
+ {productIds.map((productId) => ( + + ))} +
+ ); +}; + +const selectProducts = () => (state: State) => { + return productSelectors.selectIds(state).map(Number); +}; diff --git a/src/webshop/components/ProductList/index.tsx b/src/webshop/components/ProductList/index.tsx new file mode 100644 index 00000000..e09e765d --- /dev/null +++ b/src/webshop/components/ProductList/index.tsx @@ -0,0 +1,22 @@ +import React, { FC, useEffect } from 'react'; + +import Heading from 'common/components/Heading'; +import { useDispatch } from 'core/redux/hooks'; +import { fetchProducts } from 'webshop/slices/products'; + +import { ProductResults } from './ProductResults'; + +export const ProductList: FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchProducts()); + }, [dispatch]); + + return ( +
+ + +
+ ); +}; diff --git a/src/webshop/models/index.ts b/src/webshop/models/index.ts index e2ae36f2..32588f1e 100644 --- a/src/webshop/models/index.ts +++ b/src/webshop/models/index.ts @@ -2,6 +2,7 @@ import IResponsiveImage from 'common/models/ResponsiveImage'; import { IPayment } from 'payments/models/Payment'; export interface ISize { + id: number; size: string; description: string | null; stock: number | null; diff --git a/src/webshop/slices/productCategory.ts b/src/webshop/slices/productCategory.ts new file mode 100644 index 00000000..b4d1a836 --- /dev/null +++ b/src/webshop/slices/productCategory.ts @@ -0,0 +1,44 @@ +import { createEntityAdapter, createSlice, SerializedError, PayloadAction } from '@reduxjs/toolkit'; + +import { State } from 'core/redux/Store'; + +import { IProductCategory } from '../models'; + +const productCategoriesAdapter = createEntityAdapter({ + sortComparer: (productCategoryA, productCategoryB) => { + return productCategoryA.id - productCategoryB.id; + }, +}); + +export const productSizeSelectors = productCategoriesAdapter.getSelectors( + (state) => state.webshopProductCategories +); + +interface IState { + loading: 'idle' | 'pending'; + error: SerializedError | null; + entities: Record; +} + +const INITIAL_STATE: IState = { + loading: 'idle', + error: null, + entities: {}, +}; + +export const productCategoriesSlice = createSlice({ + name: 'webshopProductCategories', + initialState: productCategoriesAdapter.getInitialState(INITIAL_STATE), + reducers: { + addProductCategory(state, action: PayloadAction) { + productCategoriesAdapter.addOne(state, action.payload); + }, + addProductCategories(state, action: PayloadAction) { + productCategoriesAdapter.addMany(state, action.payload); + }, + }, +}); + +export const { addProductCategory, addProductCategories } = productCategoriesSlice.actions; + +export const webshopProductCategoriesReducer = productCategoriesSlice.reducer; diff --git a/src/webshop/slices/productSize.ts b/src/webshop/slices/productSize.ts new file mode 100644 index 00000000..e1f0db69 --- /dev/null +++ b/src/webshop/slices/productSize.ts @@ -0,0 +1,42 @@ +import { createEntityAdapter, createSlice, SerializedError, PayloadAction } from '@reduxjs/toolkit'; + +import { State } from 'core/redux/Store'; + +import { ISize } from '../models'; + +const productSizesAdapter = createEntityAdapter({ + sortComparer: (productSizeA, productSizeB) => { + return productSizeA.id - productSizeB.id; + }, +}); + +export const productSizeSelectors = productSizesAdapter.getSelectors((state) => state.webshopProductSizes); + +interface IState { + loading: 'idle' | 'pending'; + error: SerializedError | null; + entities: Record; +} + +const INITIAL_STATE: IState = { + loading: 'idle', + error: null, + entities: {}, +}; + +export const productSizesSlice = createSlice({ + name: 'webshopProductSizes', + initialState: productSizesAdapter.getInitialState(INITIAL_STATE), + reducers: { + addProductSize(state, action: PayloadAction) { + productSizesAdapter.addOne(state, action.payload); + }, + addProductSizes(state, action: PayloadAction) { + productSizesAdapter.addMany(state, action.payload); + }, + }, +}); + +export const { addProductSize, addProductSizes } = productSizesSlice.actions; + +export const webshopProductSizesReducer = productSizesSlice.reducer; diff --git a/src/webshop/slices/products.ts b/src/webshop/slices/products.ts new file mode 100644 index 00000000..184cadd0 --- /dev/null +++ b/src/webshop/slices/products.ts @@ -0,0 +1,91 @@ +import { createAsyncThunk, createEntityAdapter, createSlice, SerializedError } from '@reduxjs/toolkit'; + +import { State } from 'core/redux/Store'; + +import { retrieveProduct, listProducts } from '../api/products'; +import { IProduct } from '../models'; +import { addProductCategories, addProductCategory } from './productCategory'; +import { addProductSizes } from './productSize'; + +const productsAdapter = createEntityAdapter({ + sortComparer: (productA, productB) => { + return productA.name.localeCompare(productB.name); + }, +}); + +export const productSelectors = productsAdapter.getSelectors((state) => state.webshopProducts); + +export const fetchProductById = createAsyncThunk( + 'webshopProducts/fetchById', + async (productId: number, { dispatch }) => { + const response = await retrieveProduct(productId); + if (response.status === 'success') { + const product = response.data; + dispatch(addProductCategory(product.category)); + const sizes = product.product_sizes.flatMap((size) => size); + dispatch(addProductSizes(sizes)); + return product; + } else { + throw response.errors; + } + } +); + +export const fetchProducts = createAsyncThunk('webshopProducts/fetchList', async (_, { dispatch }) => { + const response = await listProducts(); + if (response.status === 'success') { + const products = response.data.results; + const categories = products.flatMap((product) => product.category); + dispatch(addProductCategories(categories)); + const sizes = products.flatMap((product) => product.product_sizes.flatMap((size) => size)); + dispatch(addProductSizes(sizes)); + return products; + } else { + throw response.errors; + } +}); + +interface IState { + loading: 'idle' | 'pending'; + error: SerializedError | null; + entities: Record; +} + +const INITIAL_STATE: IState = { + loading: 'idle', + error: null, + entities: {}, +}; + +export const productsSlice = createSlice({ + name: 'webshopProducts', + initialState: productsAdapter.getInitialState(INITIAL_STATE), + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchProductById.pending, (state) => { + state.loading = 'pending'; + }); + builder.addCase(fetchProductById.fulfilled, (state, action) => { + state.loading = 'idle'; + productsAdapter.addOne(state, action.payload); + }); + builder.addCase(fetchProductById.rejected, (state, action) => { + state.loading = 'idle'; + state.error = action.error; + }); + builder.addCase(fetchProducts.pending, (state) => { + state.loading = 'pending'; + }); + builder.addCase(fetchProducts.fulfilled, (state, action) => { + state.loading = 'idle'; + const products = action.payload; + productsAdapter.addMany(state, products); + }); + builder.addCase(fetchProducts.rejected, (state, action) => { + state.loading = 'idle'; + state.error = action.error; + }); + }, +}); + +export const webshopProductsReducer = productsSlice.reducer;