Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Webshop skeleton #414

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/appUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}`;
6 changes: 6 additions & 0 deletions src/core/redux/Store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -42,6 +45,9 @@ export const initStore = (initialState: {} = {}) => {
ruleBundles: ruleBundlesReducer,
shop: shopReducer,
transactions: transactionsReducer,
webshopProductCategories: webshopProductCategoriesReducer,
webshopProducts: webshopProductsReducer,
webshopProductSizes: webshopProductSizesReducer,
},
/* eslint sort-keys: "off" */
});
Expand Down
9 changes: 9 additions & 0 deletions src/pages/webshop/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';

import { ProductList } from 'webshop/components/ProductList';

const WebshopIndex = () => {
return <ProductList />;
};

export default WebshopIndex;
8 changes: 8 additions & 0 deletions src/webshop/api/products.ts
Original file line number Diff line number Diff line change
@@ -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<IProduct>(API_URL);

export const retrieveProduct = retrieveResource<IProduct>(API_URL);
44 changes: 44 additions & 0 deletions src/webshop/components/ProductList/ProductCard/ProductCard.less
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions src/webshop/components/ProductList/ProductCard/index.tsx
Original file line number Diff line number Diff line change
@@ -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<IProps> = ({ productId }) => {
const product = useSelector(selectProductById(productId));
return (
<Link {...getCompanyUrl(productId)}>
<a className={style.cproductCard}>
<div className={style.imageContainer}>
<ResponsiveImage image={product.images[0]} size="sm" type="product" />
</div>
<h2 className={style.title}>{product.name}</h2>
<Markdown source={product.short} />
<div className={style.detailsContainer}>
<p className={style.price}>Pris: {product.price} kr</p>
</div>
</a>
</Link>
);
};

const selectProductById = (productId: number) => (state: State) => {
return productSelectors.selectById(state, productId) as IProduct;
};
21 changes: 21 additions & 0 deletions src/webshop/components/ProductList/ProductResults.less
Original file line number Diff line number Diff line change
@@ -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);
}
}
24 changes: 24 additions & 0 deletions src/webshop/components/ProductList/ProductResults.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={style.productsContainer}>
{productIds.map((productId) => (
<ProductCard key={productId} productId={productId} />
))}
</div>
);
};

const selectProducts = () => (state: State) => {
return productSelectors.selectIds(state).map(Number);
};
22 changes: 22 additions & 0 deletions src/webshop/components/ProductList/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<Heading title="Produkter" />
<ProductResults />
</section>
);
};
1 change: 1 addition & 0 deletions src/webshop/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions src/webshop/slices/productCategory.ts
Original file line number Diff line number Diff line change
@@ -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<IProductCategory>({
sortComparer: (productCategoryA, productCategoryB) => {
return productCategoryA.id - productCategoryB.id;
},
});

export const productSizeSelectors = productCategoriesAdapter.getSelectors<State>(
(state) => state.webshopProductCategories
);

interface IState {
loading: 'idle' | 'pending';
error: SerializedError | null;
entities: Record<number, IProductCategory>;
}

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<IProductCategory>) {
productCategoriesAdapter.addOne(state, action.payload);
},
addProductCategories(state, action: PayloadAction<IProductCategory[]>) {
productCategoriesAdapter.addMany(state, action.payload);
},
},
});

export const { addProductCategory, addProductCategories } = productCategoriesSlice.actions;

export const webshopProductCategoriesReducer = productCategoriesSlice.reducer;
42 changes: 42 additions & 0 deletions src/webshop/slices/productSize.ts
Original file line number Diff line number Diff line change
@@ -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<ISize>({
sortComparer: (productSizeA, productSizeB) => {
return productSizeA.id - productSizeB.id;
},
});

export const productSizeSelectors = productSizesAdapter.getSelectors<State>((state) => state.webshopProductSizes);

interface IState {
loading: 'idle' | 'pending';
error: SerializedError | null;
entities: Record<number, ISize>;
}

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<ISize>) {
productSizesAdapter.addOne(state, action.payload);
},
addProductSizes(state, action: PayloadAction<ISize[]>) {
productSizesAdapter.addMany(state, action.payload);
},
},
});

export const { addProductSize, addProductSizes } = productSizesSlice.actions;

export const webshopProductSizesReducer = productSizesSlice.reducer;
91 changes: 91 additions & 0 deletions src/webshop/slices/products.ts
Original file line number Diff line number Diff line change
@@ -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<IProduct>({
sortComparer: (productA, productB) => {
return productA.name.localeCompare(productB.name);
},
});

export const productSelectors = productsAdapter.getSelectors<State>((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<number, IProduct>;
}

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;