From 3ebee9a6456ce9f02420d80582e5ea36db7701ef Mon Sep 17 00:00:00 2001 From: bitbybit <1628191+bitbybit@users.noreply.github.com> Date: Sun, 15 Jun 2025 15:16:17 +0200 Subject: [PATCH 1/2] refactor: ECOM-84 rename ITEMS_PER_PAGE --- app/api/namespaces/product.ts | 9 +++- .../catalog/FilterForm/FilterFormBody.tsx | 4 +- app/pages/catalog/ProductList.tsx | 4 +- .../catalog/SearchForm/SearchFomBody.tsx | 4 +- app/pages/catalog/hooks/useCatalogData.ts | 39 ++++++++++++++--- .../catalog/hooks/useCatalogProductsData.ts | 4 +- app/pages/catalog/index.tsx | 43 +++++++++---------- 7 files changed, 70 insertions(+), 37 deletions(-) diff --git a/app/api/namespaces/product.ts b/app/api/namespaces/product.ts index d16d16a..ecca742 100644 --- a/app/api/namespaces/product.ts +++ b/app/api/namespaces/product.ts @@ -68,7 +68,7 @@ export type ProductListAppliedSort = { value: 'asc' | 'desc' }[] -export const ITEMS_PER_PAGE = 12 +export const PRODUCT_LIST_ITEMS_PER_PAGE = 12 export const PRODUCT_LIST_FILTER_TRUE = 'T' export const PRODUCT_LIST_FILTER_FALSE = 'F' export const PRODUCT_LIST_FILTER_NONE = 'none' @@ -97,6 +97,13 @@ export class ProductApi { this.client = client } + public static getPaginationQueryParameters(page: number): { limit: number; offset: number } { + return { + limit: PRODUCT_LIST_ITEMS_PER_PAGE, + offset: (page - 1) * PRODUCT_LIST_ITEMS_PER_PAGE + } + } + private static convertAttributeToFilter({ label, type, name }: AttributeDefinition): ProductListFilter { const options: ProductListFilterFromAttributes['options'] = type.name === 'set' && 'values' in type.elementType diff --git a/app/pages/catalog/FilterForm/FilterFormBody.tsx b/app/pages/catalog/FilterForm/FilterFormBody.tsx index f191f7f..f79d25a 100644 --- a/app/pages/catalog/FilterForm/FilterFormBody.tsx +++ b/app/pages/catalog/FilterForm/FilterFormBody.tsx @@ -12,7 +12,7 @@ import { PRODUCT_LIST_SORT_DESC, type ProductListCategory, PRODUCT_LIST_DEFAULT_APPLIED_FILTERS, - ITEMS_PER_PAGE + PRODUCT_LIST_ITEMS_PER_PAGE } from '~/api/namespaces/product' import { Sidebar, @@ -170,7 +170,7 @@ export function FilterFormBody({ filters, categories, fetch, onApply }: FilterFo const handleApply = (data: FormValues): Promise => { onApply() return fetch( - { limit: ITEMS_PER_PAGE }, + { limit: PRODUCT_LIST_ITEMS_PER_PAGE }, convertFormValuesToAppliedFilters(data, filters), convertFormValuesToSort(data, sorts) ) diff --git a/app/pages/catalog/ProductList.tsx b/app/pages/catalog/ProductList.tsx index 50f5bb4..b23c949 100644 --- a/app/pages/catalog/ProductList.tsx +++ b/app/pages/catalog/ProductList.tsx @@ -3,7 +3,7 @@ import { type ProductProjection } from '@commercetools/platform-sdk' import { Skeleton } from '~/components/ui/Skeleton' import { ProductItem } from './ProductItem' import { CATALOG_STATUS } from './hooks/useCatalogData' -import { ITEMS_PER_PAGE } from '~/api/namespaces/product' +import { PRODUCT_LIST_ITEMS_PER_PAGE } from '~/api/namespaces/product' type ProductListProperties = { products: ProductProjection[] @@ -17,7 +17,7 @@ export function ProductList({ products, status }: ProductListProperties): ReactE
{isNotReady ? // TODO: move to separate component - Array.from({ length: ITEMS_PER_PAGE }).map((_, index) => ( + Array.from({ length: PRODUCT_LIST_ITEMS_PER_PAGE }).map((_, index) => ( )) : products.map((product) => )} diff --git a/app/pages/catalog/SearchForm/SearchFomBody.tsx b/app/pages/catalog/SearchForm/SearchFomBody.tsx index afe0dbd..c75e19b 100644 --- a/app/pages/catalog/SearchForm/SearchFomBody.tsx +++ b/app/pages/catalog/SearchForm/SearchFomBody.tsx @@ -4,7 +4,7 @@ import { Search } from 'lucide-react' import { Input } from '~/components/ui/Input' import { Button } from '~/components/ui/Button' import { type UseCatalogDataResult } from '../hooks/useCatalogData' -import { ITEMS_PER_PAGE } from '~/api/namespaces/product' +import { PRODUCT_LIST_ITEMS_PER_PAGE } from '~/api/namespaces/product' type SearchFormBodyProperties = { fetch: UseCatalogDataResult['fetchProducts'] @@ -19,7 +19,7 @@ export function SearchFormBody({ fetch, setSearch, onSearch }: SearchFormBodyPro onSearch() const { search } = getValues() setSearch(search) - return fetch({ limit: ITEMS_PER_PAGE }, [], [], search) + return fetch({ limit: PRODUCT_LIST_ITEMS_PER_PAGE }, [], [], search) } return ( diff --git a/app/pages/catalog/hooks/useCatalogData.ts b/app/pages/catalog/hooks/useCatalogData.ts index b344dec..9e88d83 100644 --- a/app/pages/catalog/hooks/useCatalogData.ts +++ b/app/pages/catalog/hooks/useCatalogData.ts @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, type Dispatch, type SetStateAction } from 'react' import { useParams } from 'react-router' +import { PRODUCT_LIST_ITEMS_PER_PAGE } from '~/api/namespaces/product' import { useCatalogCategoriesData, type UseCatalogCategoriesDataResult } from './useCatalogCategoriesData' import { useCatalogProductsData, type UseCatalogProductsDataResult } from './useCatalogProductsData' import { useCatalogFiltersData, type UseCatalogFiltersDataResult } from './useCatalogFiltersData' @@ -11,26 +12,52 @@ export enum CATALOG_STATUS { } export type UseCatalogDataResult = { + categories: UseCatalogCategoriesDataResult['categories'] + currentPage: number fetchProducts: UseCatalogProductsDataResult['fetchProducts'] filters: UseCatalogFiltersDataResult['filters'] + hasNoProducts: boolean + pagesCount: number products: UseCatalogProductsDataResult['products'] - categories: UseCatalogCategoriesDataResult['categories'] + resetCurrentPageAndSearchText: () => void + searchText: string + setCurrentPage: Dispatch> + setSearchText: Dispatch> status: CATALOG_STATUS - total: number } export function useCatalogData(): UseCatalogDataResult { const [status, setStatus] = useState(CATALOG_STATUS.LOADING) - + const [currentPage, setCurrentPage] = useState(1) + const [searchText, setSearchText] = useState('') const { categoryId = '' } = useParams() - const { categories, fetchCategories } = useCatalogCategoriesData({ setStatus }) const { products, fetchProducts, total } = useCatalogProductsData({ setStatus }) const { filters, fetchFilters } = useCatalogFiltersData({ setStatus }) + const hasNoProducts = status === CATALOG_STATUS.READY && products.length === 0 + const pagesCount = Math.ceil(total / PRODUCT_LIST_ITEMS_PER_PAGE) + + const resetCurrentPageAndSearchText = (): void => { + setCurrentPage(1) + setSearchText('') + } useEffect(() => void Promise.all([fetchFilters(), fetchProducts(), fetchCategories()]), []) useEffect(() => void fetchProducts(), [categoryId]) - return { products, filters, categories, status, total, fetchProducts } + return { + categories, + currentPage, + fetchProducts, + filters, + hasNoProducts, + pagesCount, + products, + resetCurrentPageAndSearchText, + searchText, + setCurrentPage, + setSearchText, + status + } } diff --git a/app/pages/catalog/hooks/useCatalogProductsData.ts b/app/pages/catalog/hooks/useCatalogProductsData.ts index bb34cdb..f8db257 100644 --- a/app/pages/catalog/hooks/useCatalogProductsData.ts +++ b/app/pages/catalog/hooks/useCatalogProductsData.ts @@ -3,7 +3,7 @@ import { useParams } from 'react-router' import { type ProductProjection } from '@commercetools/platform-sdk' import { toast } from 'sonner' import { - ITEMS_PER_PAGE, + PRODUCT_LIST_ITEMS_PER_PAGE, PRODUCT_LIST_DEFAULT_APPLIED_FILTERS, PRODUCT_LIST_DEFAULT_APPLIED_SORT, productApi, @@ -63,7 +63,7 @@ export function useCatalogProductsData({ try { const response = await productApi.getProducts( - { ...parameters, limit: ITEMS_PER_PAGE }, + { ...parameters, limit: PRODUCT_LIST_ITEMS_PER_PAGE }, cache.filters, cache.sort, searchText, diff --git a/app/pages/catalog/index.tsx b/app/pages/catalog/index.tsx index c65bb76..4d250a5 100644 --- a/app/pages/catalog/index.tsx +++ b/app/pages/catalog/index.tsx @@ -1,48 +1,47 @@ -import { useState, type ReactElement } from 'react' +import { type ReactElement } from 'react' import { useTitle } from '~/hooks/useTitle' +import { ProductApi } from '~/api/namespaces/product' import { Loading } from '~/components/Loading' import { ProductList } from './ProductList' import { FilterFormBody } from './FilterForm/FilterFormBody' -import { CATALOG_STATUS, useCatalogData } from './hooks/useCatalogData' +import { useCatalogData } from './hooks/useCatalogData' import { SearchFormBody } from './SearchForm/SearchFomBody' import { NoProductsFound } from './NoProductsFound' import { PaginationControls } from './PaginationControls' -import { ITEMS_PER_PAGE } from '~/api/namespaces/product' export default function Catalog(): ReactElement { useTitle('Catalog') - const { products, filters, status, categories, fetchProducts, total } = useCatalogData() - const hasNoProducts = status === CATALOG_STATUS.READY && products.length === 0 - const [currentPage, setCurrentPage] = useState(1) - const [searchText, setSearchText] = useState('') - const pagesCount = Math.ceil(total / ITEMS_PER_PAGE) + const data = useCatalogData() const handlePageChange = (page: number): void => { - setCurrentPage(page) - void fetchProducts({ limit: ITEMS_PER_PAGE, offset: (page - 1) * ITEMS_PER_PAGE }, [], [], searchText) + data.setCurrentPage(page) + void data.fetchProducts(ProductApi.getPaginationQueryParameters(page), [], [], data.searchText) } - if (filters.length === 0) { + + if (data.filters.length === 0) { return } + return (
{ - setCurrentPage(1) - setSearchText('') - }} + filters={data.filters} + categories={data.categories} + fetch={data.fetchProducts} + onApply={data.resetCurrentPageAndSearchText} />
- setCurrentPage(1)} /> - {hasNoProducts ? ( + data.setCurrentPage(1)} + /> + {data.hasNoProducts ? ( ) : (
- - + +
)}
From 36ffc1ea0d97d735200fda613e4c8546b78d4662 Mon Sep 17 00:00:00 2001 From: bitbybit <1628191+bitbybit@users.noreply.github.com> Date: Sun, 15 Jun 2025 18:49:47 +0200 Subject: [PATCH 2/2] feat: ECOM-84 cart store --- app/api/client.ts | 2 +- app/api/namespaces/cart.ts | 32 ++++++---- app/hooks/useFetchCart.ts | 9 +++ app/pages/cart/index.tsx | 76 +++++++++++++++++------- app/pages/catalog/AddToCartButton.tsx | 55 ++++++++++++----- app/pages/catalog/ProductItem.tsx | 2 +- app/pages/catalog/index.tsx | 2 + app/pages/product/index.tsx | 3 +- app/store/auth/index.ts | 9 ++- app/store/cart/index.ts | 39 +++++++++--- app/store/cart/reducers/addProduct.ts | 49 +++++++++++++++ app/store/cart/reducers/getCart.ts | 42 +++++++++++++ app/store/cart/reducers/removeProduct.ts | 51 ++++++++++++++++ app/store/cart/types.ts | 12 +++- 14 files changed, 318 insertions(+), 65 deletions(-) create mode 100644 app/hooks/useFetchCart.ts create mode 100644 app/store/cart/reducers/addProduct.ts create mode 100644 app/store/cart/reducers/getCart.ts create mode 100644 app/store/cart/reducers/removeProduct.ts diff --git a/app/api/client.ts b/app/api/client.ts index 99d8b44..61a9347 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -105,7 +105,7 @@ export class CtpApiClient { body: { email, password, - activeCartSignInMode: 'MergeWithExistingCustomerCart', + activeCartSignInMode: 'UseAsNewActiveCustomerCart', updateProductData: true } }) diff --git a/app/api/namespaces/cart.ts b/app/api/namespaces/cart.ts index 81f1c8c..6db9770 100644 --- a/app/api/namespaces/cart.ts +++ b/app/api/namespaces/cart.ts @@ -1,5 +1,5 @@ import { ctpApiClient, type CtpApiClient } from '~/api/client' -import { type Cart, type ClientResponse, type ProductProjection } from '@commercetools/platform-sdk' +import { type Cart, type ClientResponse } from '@commercetools/platform-sdk' type CartApiProperties = { client: CtpApiClient @@ -15,23 +15,23 @@ export class CartApi { this.client = client } - public async addProduct(product: ProductProjection, quantity: number = 1): Promise> { + public async addProduct(productId: string, quantity: number = 1): Promise> { const cart = await this.getCart() return this.client .getCurrentCustomerBuilder() .carts() .withId({ ID: cart.id }) - .post({ body: { actions: [{ action: 'addLineItem', productId: product.id, quantity }], version: cart.version } }) + .post({ body: { actions: [{ action: 'addLineItem', productId, quantity }], version: cart.version } }) .execute() } - public async removeProduct(product: ProductProjection, quantity: number = 1): Promise> { + public async removeProduct(productId: string, quantity: number = 1): Promise> { const cart = await this.getCart() - const lineItemId = cart.lineItems.find((lineItem) => lineItem.productId === product.id)?.id + const lineItemId = cart.lineItems.find((lineItem) => lineItem.productId === productId)?.id if (lineItemId === undefined) { - throw new Error(`Could not find lineItem for product with ID ${product.id}`) + throw new Error(`Could not find lineItem for product with ID ${productId}`) } return this.client @@ -59,13 +59,25 @@ export class CartApi { return cart.body } - const cart = carts.body.results.find((cart) => cart.id === this.getCartIdFromStorage()) + const cart = carts.body.results.find( + ({ id, cartState }) => id === this.getCartIdFromStorage() && cartState === 'Active' + ) - if (cart === undefined) { - throw new Error('Can not get cart') + if (cart !== undefined) { + return cart } - return cart + const nonEmptyCart = carts.body.results.find( + ({ lineItems, cartState }) => lineItems.length > 0 && cartState === 'Active' + ) + + if (nonEmptyCart !== undefined) { + return nonEmptyCart + } + + const activeCart = await this.client.getCurrentCustomerBuilder().activeCart().get().execute() + + return activeCart.body } private getCartIdFromStorage(): string | null { diff --git a/app/hooks/useFetchCart.ts b/app/hooks/useFetchCart.ts new file mode 100644 index 0000000..f3509c4 --- /dev/null +++ b/app/hooks/useFetchCart.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react' +import { useAppDispatch } from '~/store/hooks' +import { getCart } from '~/store/cart' + +export function useFetchCart(): void { + const dispatch = useAppDispatch() + + useEffect(() => void dispatch(getCart()), []) +} diff --git a/app/pages/cart/index.tsx b/app/pages/cart/index.tsx index 32c2968..1f9397b 100644 --- a/app/pages/cart/index.tsx +++ b/app/pages/cart/index.tsx @@ -1,31 +1,61 @@ -import { type ReactElement, useEffect } from 'react' +import { type MouseEvent, type ReactElement } from 'react' import { useTitle } from '~/hooks/useTitle' -import { cartApi } from '~/api/namespaces/cart' -import { productApi } from '~/api/namespaces/product' +import { useFetchCart } from '~/hooks/useFetchCart' +import { useAppDispatch, useAppSelector } from '~/store/hooks' +import { removeProduct, selectCartItems, selectIsEmptyCart } from '~/store/cart' import { EmptyBasket } from './EmptyBasket' +import { ProductPrice } from '~/components/product/ProductPrice' +import { formatProductItemPrice } from '~/utils/formatPrice' +import { CART_TABLE_STATUS } from '~/store/cart/types' +// TODO: remove comment +// eslint-disable-next-line max-lines-per-function export default function Routes(): ReactElement { useTitle('Cart') - - useEffect(() => { - const cartExampleCalls = async (): Promise => { - const product = await productApi.getProductById('1a4e9d76-3577-42aa-910f-17e1d68c80cc') - - const cartAfterAdd = await cartApi.addProduct(product.body, 1) - - console.log(cartAfterAdd.body) - - const cartAfterRemove = await cartApi.removeProduct(product.body, 1) - - console.log(cartAfterRemove.body) - } - - void cartExampleCalls() - }) - + useFetchCart() + + const dispatch = useAppDispatch() + const { status } = useAppSelector((state) => state.cart) + const isEmptyCart = useAppSelector(selectIsEmptyCart) && status === CART_TABLE_STATUS.READY + const cartItems = useAppSelector(selectCartItems) + + if (isEmptyCart) { + return + } + + // TODO: move to separate component + const handleClick = async ( + event: MouseEvent, + productId: string, + quantity: number + ): Promise => { + event.preventDefault() + await dispatch(removeProduct({ productId, quantity })).unwrap() + } + + // TODO: fetch products by id to get pictures return ( -
- -
+ <> + {cartItems.map(({ name, productId, quantity, price, totalPrice }) => ( +
+
+ {name['en-US']} (amount: {quantity}) +
+ + + + {quantity > 1 &&
Price of all items: {formatProductItemPrice(totalPrice.centAmount)}
} + + {status === CART_TABLE_STATUS.LOADING ? ( + // TODO: add some loader + <>... + ) : ( + void handleClick(event, productId, quantity)}> + remove + + )} +
+ ))} + ) } diff --git a/app/pages/catalog/AddToCartButton.tsx b/app/pages/catalog/AddToCartButton.tsx index 0a614c4..e50c5ae 100644 --- a/app/pages/catalog/AddToCartButton.tsx +++ b/app/pages/catalog/AddToCartButton.tsx @@ -1,23 +1,46 @@ +import { type ReactElement, type MouseEvent, useState } from 'react' import { Check, ShoppingCart } from 'lucide-react' -import type { ReactElement } from 'react' +import { type ProductProjection } from '@commercetools/platform-sdk' +import { cn } from '~/utils/ui' import { Button } from '~/components/ui/Button' +import { useAppDispatch, useAppSelector } from '~/store/hooks' +import { addProduct, selectIsInCart } from '~/store/cart' -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function AddToCartButton({ productId }: { productId: string }): ReactElement { - // TODO: - const isProductInCart = false +type AddToCartButtonProperties = { product: ProductProjection } + +export function AddToCartButton({ product }: AddToCartButtonProperties): ReactElement { + const dispatch = useAppDispatch() + const isProductInCart = useAppSelector(selectIsInCart(product)) + const [isLoading, setIsLoading] = useState(false) + + const handleClick = async (event: MouseEvent): Promise => { + event.stopPropagation() + setIsLoading(true) + try { + await dispatch(addProduct({ productId: product.id, quantity: 1 })).unwrap() + } finally { + setIsLoading(false) + } + } + + if (isLoading) { + // TODO: move to separate component + return <>... + } return ( -
event.stopPropagation()} className={isProductInCart ? 'cursor-auto' : 'cursor-pointer'}> - -
+ ) } diff --git a/app/pages/catalog/ProductItem.tsx b/app/pages/catalog/ProductItem.tsx index 27b9eb3..293f62b 100644 --- a/app/pages/catalog/ProductItem.tsx +++ b/app/pages/catalog/ProductItem.tsx @@ -38,7 +38,7 @@ export function ProductItem({ product }: ProductItemProperties): ReactElement { {description}
- +
diff --git a/app/pages/catalog/index.tsx b/app/pages/catalog/index.tsx index 4d250a5..787667b 100644 --- a/app/pages/catalog/index.tsx +++ b/app/pages/catalog/index.tsx @@ -1,5 +1,6 @@ import { type ReactElement } from 'react' import { useTitle } from '~/hooks/useTitle' +import { useFetchCart } from '~/hooks/useFetchCart' import { ProductApi } from '~/api/namespaces/product' import { Loading } from '~/components/Loading' import { ProductList } from './ProductList' @@ -11,6 +12,7 @@ import { PaginationControls } from './PaginationControls' export default function Catalog(): ReactElement { useTitle('Catalog') + useFetchCart() const data = useCatalogData() const handlePageChange = (page: number): void => { diff --git a/app/pages/product/index.tsx b/app/pages/product/index.tsx index a841936..cbf0d00 100644 --- a/app/pages/product/index.tsx +++ b/app/pages/product/index.tsx @@ -1,12 +1,13 @@ import { type ReactElement } from 'react' import { useTitle } from '~/hooks/useTitle' +import { useFetchCart } from '~/hooks/useFetchCart' import { Loading } from '~/components/Loading' import { ProductDetailBody } from './ProductDetail/ProductDetailBody' import { useProductData, PRODUCT_STATUS } from './hooks/useProductData' export default function Product(): ReactElement { useTitle('Product') - + useFetchCart() const { categories, product, status } = useProductData() if (status !== PRODUCT_STATUS.READY) { diff --git a/app/store/auth/index.ts b/app/store/auth/index.ts index 5de2cf6..d0aeee5 100644 --- a/app/store/auth/index.ts +++ b/app/store/auth/index.ts @@ -1,4 +1,4 @@ -import { createSelector } from '@reduxjs/toolkit' +import { createSelector, type Selector } from '@reduxjs/toolkit' import { type RootState } from '~/store/types' import { createAppSlice } from '~/store/hooks' import { AUTH_STATUS, type AuthState } from '~/store/auth/types' @@ -35,9 +35,12 @@ const auth = createAppSlice({ }) }) -const selectAuthSlice = (state: RootState): AuthState => state.auth +const selectAuthSlice: Selector = (state: RootState): AuthState => state.auth -export const selectIsAuth = createSelector([selectAuthSlice], (auth) => auth.customer !== undefined) +export const selectIsAuth: Selector = createSelector( + [selectAuthSlice], + (auth) => auth.customer !== undefined +) export const { signIn, signUp, logOut, checkAuth, resetAuthError, setCustomer } = auth.actions export default auth.reducer diff --git a/app/store/cart/index.ts b/app/store/cart/index.ts index d4a6958..36e3440 100644 --- a/app/store/cart/index.ts +++ b/app/store/cart/index.ts @@ -1,22 +1,43 @@ -import { type PayloadAction } from '@reduxjs/toolkit' +import { createSelector, type Selector } from '@reduxjs/toolkit' +import { type LineItem, type ProductProjection } from '@commercetools/platform-sdk' +import { type RootState } from '~/store/types' import { createAppSlice } from '~/store/hooks' -import { type CartState } from '~/store/cart/types' +import { CART_TABLE_STATUS, type CartState } from '~/store/cart/types' +import { createAddProductThunk } from '~/store/cart/reducers/addProduct' +import { createRemoveProductThunk } from '~/store/cart/reducers/removeProduct' +import { createGetCartThunk } from '~/store/cart/reducers/getCart' const initialState: CartState = { - value: undefined + cart: undefined, + status: CART_TABLE_STATUS.READY, + errorMessage: '' } const cart = createAppSlice({ name: 'cart', initialState, - reducers: { - setValue(state, action: PayloadAction) { - state.value = action.payload - } - } + reducers: (create) => ({ + addProduct: createAddProductThunk(create), + removeProduct: createRemoveProductThunk(create), + getCart: createGetCartThunk(create) + }) }) -export const { setValue } = cart.actions +const selectCartSlice: Selector = (state: RootState): CartState => state.cart +export const selectIsEmptyCart: Selector = createSelector( + [selectCartSlice], + (cart) => (cart.cart?.lineItems?.length ?? 0) === 0 +) + +export const selectCartItems: Selector = createSelector( + [selectCartSlice], + (cart) => cart.cart?.lineItems ?? [] +) + +export const selectIsInCart = (product: ProductProjection): Selector => + createSelector([selectCartItems], (items) => items.some(({ productId }) => productId === product.id)) + +export const { addProduct, removeProduct, getCart } = cart.actions export default cart.reducer diff --git a/app/store/cart/reducers/addProduct.ts b/app/store/cart/reducers/addProduct.ts new file mode 100644 index 0000000..8c04a93 --- /dev/null +++ b/app/store/cart/reducers/addProduct.ts @@ -0,0 +1,49 @@ +import { type ReducerCreators } from '@reduxjs/toolkit' +import { cartApi, type CartApi } from '~/api/namespaces/cart' +import { CART_TABLE_STATUS, type CartState } from '~/store/cart/types' + +type AddProductThunkResult = Awaited>['body'] + +type AddProductThunkPayload = { + productId: Parameters[0] + quantity: Parameters[1] +} + +type AddProductThunkConfig = { rejectValue: string } + +export const createAddProductThunk = ( + create: ReducerCreators +): ReturnType> => + create.asyncThunk( + async ({ productId, quantity }, { rejectWithValue }) => { + try { + const response = await cartApi.addProduct(productId, quantity) + + return response.body + } catch (error) { + if (error instanceof Error) { + return rejectWithValue(error.message) + } + + return rejectWithValue(String(error)) + } + }, + + { + pending: (state) => { + state.cart = undefined + state.errorMessage = '' + state.status = CART_TABLE_STATUS.LOADING + }, + + fulfilled: (state, action) => { + state.cart = action.payload + state.status = CART_TABLE_STATUS.READY + }, + + rejected: (state, action) => { + state.errorMessage = action.payload ?? 'Unknown error while adding product to cart' + state.status = CART_TABLE_STATUS.ERROR + } + } + ) diff --git a/app/store/cart/reducers/getCart.ts b/app/store/cart/reducers/getCart.ts new file mode 100644 index 0000000..9a24e8c --- /dev/null +++ b/app/store/cart/reducers/getCart.ts @@ -0,0 +1,42 @@ +import { type ReducerCreators } from '@reduxjs/toolkit' +import { cartApi, type CartApi } from '~/api/namespaces/cart' +import { CART_TABLE_STATUS, type CartState } from '~/store/cart/types' + +type GetCartThunkResult = Awaited> + +type GetCartThunkConfig = { rejectValue: string } + +export const createGetCartThunk = ( + create: ReducerCreators +): ReturnType> => + create.asyncThunk( + async (_, { rejectWithValue }) => { + try { + return await cartApi.getCart() + } catch (error) { + if (error instanceof Error) { + return rejectWithValue(error.message) + } + + return rejectWithValue(String(error)) + } + }, + + { + pending: (state) => { + state.cart = undefined + state.errorMessage = '' + state.status = CART_TABLE_STATUS.LOADING + }, + + fulfilled: (state, action) => { + state.cart = action.payload + state.status = CART_TABLE_STATUS.READY + }, + + rejected: (state, action) => { + state.errorMessage = action.payload ?? 'Unknown error while getting cart' + state.status = CART_TABLE_STATUS.ERROR + } + } + ) diff --git a/app/store/cart/reducers/removeProduct.ts b/app/store/cart/reducers/removeProduct.ts new file mode 100644 index 0000000..358ee96 --- /dev/null +++ b/app/store/cart/reducers/removeProduct.ts @@ -0,0 +1,51 @@ +import { type ReducerCreators } from '@reduxjs/toolkit' +import { cartApi, type CartApi } from '~/api/namespaces/cart' +import { CART_TABLE_STATUS, type CartState } from '~/store/cart/types' + +type RemoveProductThunkResult = Awaited>['body'] + +type RemoveProductThunkPayload = { + productId: Parameters[0] + quantity: Parameters[1] +} + +type RemoveProductThunkConfig = { rejectValue: string } + +export const createRemoveProductThunk = ( + create: ReducerCreators +): ReturnType< + typeof create.asyncThunk +> => + create.asyncThunk( + async ({ productId, quantity }, { rejectWithValue }) => { + try { + const response = await cartApi.removeProduct(productId, quantity) + + return response.body + } catch (error) { + if (error instanceof Error) { + return rejectWithValue(error.message) + } + + return rejectWithValue(String(error)) + } + }, + + { + pending: (state) => { + state.cart = undefined + state.errorMessage = '' + state.status = CART_TABLE_STATUS.LOADING + }, + + fulfilled: (state, action) => { + state.cart = action.payload + state.status = CART_TABLE_STATUS.READY + }, + + rejected: (state, action) => { + state.errorMessage = action.payload ?? 'Unknown error while removing product from cart' + state.status = CART_TABLE_STATUS.ERROR + } + } + ) diff --git a/app/store/cart/types.ts b/app/store/cart/types.ts index 2018a13..6c0eecf 100644 --- a/app/store/cart/types.ts +++ b/app/store/cart/types.ts @@ -1,3 +1,13 @@ +import { type Cart } from '@commercetools/platform-sdk' + +export enum CART_TABLE_STATUS { + LOADING = 'LOADING', + READY = 'READY', + ERROR = 'ERROR' +} + export type CartState = { - value: undefined + cart: Cart | undefined + errorMessage: string + status: CART_TABLE_STATUS }