diff --git a/app/api/namespaces/cart.ts b/app/api/namespaces/cart.ts index 6db9770..4c1fb3c 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 } from '@commercetools/platform-sdk' +import { type Cart, type ClientResponse, type DiscountCodePagedQueryResponse } from '@commercetools/platform-sdk' type CartApiProperties = { client: CtpApiClient @@ -80,6 +80,24 @@ export class CartApi { return activeCart.body } + public async getDiscountCodes(): Promise> { + return this.client.root + .discountCodes() + .get({ queryArgs: { where: 'isActive = true' } }) + .execute() + } + + public async applyDiscountCode(code: string): Promise> { + const cart = await this.getCart() + + return this.client + .getCurrentCustomerBuilder() + .carts() + .withId({ ID: cart.id }) + .post({ body: { actions: [{ action: 'addDiscountCode', code }], version: cart.version } }) + .execute() + } + private getCartIdFromStorage(): string | null { return localStorage.getItem(this.cartIdStorageKey) } diff --git a/app/pages/cart/CodeForm.tsx b/app/pages/cart/CodeForm.tsx new file mode 100644 index 0000000..470d03c --- /dev/null +++ b/app/pages/cart/CodeForm.tsx @@ -0,0 +1,52 @@ +import { type ReactElement } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Form } from '~/components/ui/Form' +import { Card, CardContent, CardHeader, CardTitle } from '~/components/ui/Card' +import { Button } from '~/components/ui/Button' +import { Input } from '~/components/ui/Input' +import { useAppDispatch, useAppSelector } from '~/store/hooks' +import { applyCode } from '~/store/cart' +import { CART_TABLE_STATUS } from '~/store/cart/types' + +const schema = z.object({ + code: z.string() +}) + +const defaultValues = { + code: '' +} + +export type SchemaType = typeof schema + +export const CodeForm = (): ReactElement => { + const dispatch = useAppDispatch() + const { status, errorMessage } = useAppSelector((state) => state.cart) + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues + }) + + const handleApplyCode = (payload: z.infer): void => void dispatch(applyCode(payload)) + + return ( + + + APPLY CODE + + {status === CART_TABLE_STATUS.ERROR &&
{errorMessage}
} +
+ void form.handleSubmit(handleApplyCode)(event)}> +
+ + + + +
+
+ +
+ ) +} diff --git a/app/pages/cart/index.tsx b/app/pages/cart/index.tsx index 1f9397b..c71a8cd 100644 --- a/app/pages/cart/index.tsx +++ b/app/pages/cart/index.tsx @@ -4,6 +4,7 @@ import { useFetchCart } from '~/hooks/useFetchCart' import { useAppDispatch, useAppSelector } from '~/store/hooks' import { removeProduct, selectCartItems, selectIsEmptyCart } from '~/store/cart' import { EmptyBasket } from './EmptyBasket' +import { CodeForm } from './CodeForm' import { ProductPrice } from '~/components/product/ProductPrice' import { formatProductItemPrice } from '~/utils/formatPrice' import { CART_TABLE_STATUS } from '~/store/cart/types' @@ -15,7 +16,7 @@ export default function Routes(): ReactElement { useFetchCart() const dispatch = useAppDispatch() - const { status } = useAppSelector((state) => state.cart) + const { status, cart } = useAppSelector((state) => state.cart) const isEmptyCart = useAppSelector(selectIsEmptyCart) && status === CART_TABLE_STATUS.READY const cartItems = useAppSelector(selectCartItems) @@ -32,12 +33,12 @@ export default function Routes(): ReactElement { event.preventDefault() await dispatch(removeProduct({ productId, quantity })).unwrap() } - - // TODO: fetch products by id to get pictures return ( <> - {cartItems.map(({ name, productId, quantity, price, totalPrice }) => ( -
+ {cartItems.map(({ name, productId, quantity, price, totalPrice, variant }) => ( +
+ Preview +
{name['en-US']} (amount: {quantity})
@@ -56,6 +57,15 @@ export default function Routes(): ReactElement { )}
))} + {cart?.discountOnTotalPrice === undefined ? ( +
Total price: {formatProductItemPrice(cart?.totalPrice?.centAmount ?? 0)}
+ ) : ( +
+ Total price with discount code applied:{' '} + {formatProductItemPrice(cart?.discountOnTotalPrice?.discountedAmount?.centAmount ?? 0)} +
+ )} + ) } diff --git a/app/pages/catalog/FilterForm/FilterFormBody.tsx b/app/pages/catalog/FilterForm/FilterFormBody.tsx index f79d25a..3efc250 100644 --- a/app/pages/catalog/FilterForm/FilterFormBody.tsx +++ b/app/pages/catalog/FilterForm/FilterFormBody.tsx @@ -28,9 +28,6 @@ import { SortFormField } from './SortFormField' import { Categories } from './Categories' import { type UseCatalogDataResult } from '../hooks/useCatalogData' -// TODO: items per page -export const PRODUCTS_LIMIT = 100 - type FilterFormBodyProperties = { filters: ProductListFilter[] categories: ProductListCategory[] diff --git a/app/pages/home/Discounts.tsx b/app/pages/home/Discounts.tsx new file mode 100644 index 0000000..48adae7 --- /dev/null +++ b/app/pages/home/Discounts.tsx @@ -0,0 +1,28 @@ +import { type ReactElement } from 'react' +import { useDiscountsData } from './hooks/useDiscountsData' +import { Card, CardContent, CardDescription, CardTitle } from '~/components/ui/Card' +import { H2 } from '~/components/ui/typography' + +export function Discounts(): ReactElement { + const { discounts } = useDiscountsData() + + if (discounts.length === 0) { + return <> + } + + return ( +
+

Discounts

+
+ {discounts.map(({ id, code, description }) => ( + + + {code} + {description !== undefined && {description['en-US']}} + + + ))} +
+
+ ) +} diff --git a/app/pages/home/Welcome.tsx b/app/pages/home/Welcome.tsx new file mode 100644 index 0000000..bc63b08 --- /dev/null +++ b/app/pages/home/Welcome.tsx @@ -0,0 +1,33 @@ +import { type ReactElement } from 'react' +import { NavLink } from 'react-router' +import { H2, P } from '~/components/ui/typography' +import { Button } from '~/components/ui/Button' +import { useAppSelector } from '~/store/hooks' +import { selectIsAuth } from '~/store/auth' +import { ROUTES } from '~/routes' + +export function Welcome(): ReactElement { + const isAuth = useAppSelector(selectIsAuth) + const userName = useAppSelector((state) => state.auth.customer?.email ?? '') + const greeting = isAuth ? `Welcome back, ${userName}!` : 'Welcome to our store!' + + return ( +
+

{greeting}

+ +

Discover products, manage your profile, and enjoy smooth shopping!

+ +
+ + + {!isAuth && ( + + )} +
+
+ ) +} diff --git a/app/pages/home/hooks/useDiscountsData.tsx b/app/pages/home/hooks/useDiscountsData.tsx new file mode 100644 index 0000000..3765d9b --- /dev/null +++ b/app/pages/home/hooks/useDiscountsData.tsx @@ -0,0 +1,38 @@ +import { type DiscountCode } from '@commercetools/platform-sdk' +import { useState, useEffect } from 'react' +import { toast } from 'sonner' +import { cartApi } from '~/api/namespaces/cart' + +export enum DISCOUNTS_STATUS { + LOADING = 'LOADING', + READY = 'READY', + ERROR = 'ERROR' +} + +type UseDiscountsDataResult = { + discounts: DiscountCode[] + status: DISCOUNTS_STATUS +} + +export function useDiscountsData(): UseDiscountsDataResult { + const [discounts, setDiscounts] = useState([]) + const [status, setStatus] = useState(DISCOUNTS_STATUS.LOADING) + + useEffect(() => { + async function fetchDiscountsData(): Promise { + setStatus(DISCOUNTS_STATUS.LOADING) + try { + const response = await cartApi.getDiscountCodes() + setDiscounts(response.body.results) + setStatus(DISCOUNTS_STATUS.READY) + } catch (error) { + setStatus(DISCOUNTS_STATUS.ERROR) + toast(error instanceof Error ? error.message : 'Unknown error while getting discounts data') + } + } + + void fetchDiscountsData() + }, []) + + return { discounts, status } +} diff --git a/app/pages/home/index.tsx b/app/pages/home/index.tsx index 32e193a..1d9df8b 100644 --- a/app/pages/home/index.tsx +++ b/app/pages/home/index.tsx @@ -1,36 +1,15 @@ import { type ReactElement } from 'react' import { useTitle } from '~/hooks/useTitle' -import { NavLink } from 'react-router' -import { H2, P } from '~/components/ui/typography' -import { Button } from '~/components/ui/Button' -import { useAppSelector } from '~/store/hooks' -import { selectIsAuth } from '~/store/auth' -import { ROUTES } from '~/routes' +import { Welcome } from './Welcome' +import { Discounts } from './Discounts' export default function Home(): ReactElement { useTitle('eCommerce') - const isAuth = useAppSelector(selectIsAuth) - const userName = useAppSelector((state) => state.auth.customer?.email ?? '') - const greeting = isAuth ? `Welcome back, ${userName}!` : 'Welcome to our store!' - return ( -
-

{greeting}

- -

Discover products, manage your profile, and enjoy smooth shopping!

- -
- - - {!isAuth && ( - - )} -
+
+ +
) } diff --git a/app/store/cart/index.ts b/app/store/cart/index.ts index 36e3440..aa6cb6e 100644 --- a/app/store/cart/index.ts +++ b/app/store/cart/index.ts @@ -6,6 +6,7 @@ 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' +import { createApplyCodeThunk } from '~/store/cart/reducers/applyCode' const initialState: CartState = { cart: undefined, @@ -20,7 +21,8 @@ const cart = createAppSlice({ reducers: (create) => ({ addProduct: createAddProductThunk(create), removeProduct: createRemoveProductThunk(create), - getCart: createGetCartThunk(create) + getCart: createGetCartThunk(create), + applyCode: createApplyCodeThunk(create) }) }) @@ -39,5 +41,5 @@ export const selectCartItems: Selector = createSelector( export const selectIsInCart = (product: ProductProjection): Selector => createSelector([selectCartItems], (items) => items.some(({ productId }) => productId === product.id)) -export const { addProduct, removeProduct, getCart } = cart.actions +export const { addProduct, removeProduct, getCart, applyCode } = cart.actions export default cart.reducer diff --git a/app/store/cart/reducers/addProduct.ts b/app/store/cart/reducers/addProduct.ts index 8c04a93..f9553db 100644 --- a/app/store/cart/reducers/addProduct.ts +++ b/app/store/cart/reducers/addProduct.ts @@ -31,7 +31,6 @@ export const createAddProductThunk = ( { pending: (state) => { - state.cart = undefined state.errorMessage = '' state.status = CART_TABLE_STATUS.LOADING }, diff --git a/app/store/cart/reducers/applyCode.ts b/app/store/cart/reducers/applyCode.ts new file mode 100644 index 0000000..0fe71f1 --- /dev/null +++ b/app/store/cart/reducers/applyCode.ts @@ -0,0 +1,47 @@ +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 ApplyCodeThunkResult = Awaited>['body'] + +type ApplyCodeThunkPayload = { + code: Parameters[0] +} + +type ApplyCodeThunkConfig = { rejectValue: string } + +export const createApplyCodeThunk = ( + create: ReducerCreators +): ReturnType> => + create.asyncThunk( + async ({ code }, { rejectWithValue }) => { + try { + const response = await cartApi.applyDiscountCode(code) + + return response.body + } catch (error) { + if (error instanceof Error) { + return rejectWithValue(error.message) + } + + return rejectWithValue(String(error)) + } + }, + + { + pending: (state) => { + 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/removeProduct.ts b/app/store/cart/reducers/removeProduct.ts index 358ee96..4399ae7 100644 --- a/app/store/cart/reducers/removeProduct.ts +++ b/app/store/cart/reducers/removeProduct.ts @@ -33,7 +33,6 @@ export const createRemoveProductThunk = ( { pending: (state) => { - state.cart = undefined state.errorMessage = '' state.status = CART_TABLE_STATUS.LOADING },