Skip to content
This repository was archived by the owner on Jul 3, 2025. It is now read-only.
Merged
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
20 changes: 19 additions & 1 deletion app/api/namespaces/cart.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -80,6 +80,24 @@ export class CartApi {
return activeCart.body
}

public async getDiscountCodes(): Promise<ClientResponse<DiscountCodePagedQueryResponse>> {
return this.client.root
.discountCodes()
.get({ queryArgs: { where: 'isActive = true' } })
.execute()
}

public async applyDiscountCode(code: string): Promise<ClientResponse<Cart>> {
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)
}
Expand Down
52 changes: 52 additions & 0 deletions app/pages/cart/CodeForm.tsx
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues
})

const handleApplyCode = (payload: z.infer<SchemaType>): void => void dispatch(applyCode(payload))

return (
<Card className="md:min-w-lg m-6 gap-2">
<CardHeader>
<CardTitle>APPLY CODE</CardTitle>
</CardHeader>
{status === CART_TABLE_STATUS.ERROR && <div className="px-6 text-red-900">{errorMessage}</div>}
<Form {...form}>
<form onSubmit={(event) => void form.handleSubmit(handleApplyCode)(event)}>
<fieldset disabled={status === CART_TABLE_STATUS.LOADING}>
<CardContent className="flex gap-2">
<Input type="text" placeholder="Code" {...form.register('code')} />
<Button type="submit">Submit</Button>
</CardContent>
</fieldset>
</form>
</Form>
</Card>
)
}
20 changes: 15 additions & 5 deletions app/pages/cart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)

Expand All @@ -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 }) => (
<div key={productId} className="flex gap-5">
{cartItems.map(({ name, productId, quantity, price, totalPrice, variant }) => (
<div key={productId} className="flex items-center gap-5">
<img src={variant.images?.[0]?.url} alt="Preview" width="25" />

<div>
{name['en-US']} (amount: {quantity})
</div>
Expand All @@ -56,6 +57,15 @@ export default function Routes(): ReactElement {
)}
</div>
))}
{cart?.discountOnTotalPrice === undefined ? (
<div>Total price: {formatProductItemPrice(cart?.totalPrice?.centAmount ?? 0)}</div>
) : (
<div>
Total price with discount code applied:{' '}
{formatProductItemPrice(cart?.discountOnTotalPrice?.discountedAmount?.centAmount ?? 0)}
</div>
)}
<CodeForm />
</>
)
}
3 changes: 0 additions & 3 deletions app/pages/catalog/FilterForm/FilterFormBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
28 changes: 28 additions & 0 deletions app/pages/home/Discounts.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-6 mx-3">
<H2 className="text-center mb-6">Discounts</H2>
<div className="flex gap-3">
{discounts.map(({ id, code, description }) => (
<Card key={id} className="flex-grow max-w-1/2 p-2">
<CardContent className="p-2">
<CardTitle className="mb-2">{code}</CardTitle>
{description !== undefined && <CardDescription>{description['en-US']}</CardDescription>}
</CardContent>
</Card>
))}
</div>
</div>
)
}
33 changes: 33 additions & 0 deletions app/pages/home/Welcome.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="text-center">
<H2>{greeting}</H2>

<P className="mb-6 text-gray-600">Discover products, manage your profile, and enjoy smooth shopping!</P>

<div className="flex gap-4 flex-wrap justify-center">
<Button variant="blue" asChild>
<NavLink to={ROUTES.CATALOG}>Browse Catalog</NavLink>
</Button>

{!isAuth && (
<Button variant="gray" asChild>
<NavLink to={ROUTES.LOGIN}>Login</NavLink>
</Button>
)}
</div>
</div>
)
}
38 changes: 38 additions & 0 deletions app/pages/home/hooks/useDiscountsData.tsx
Original file line number Diff line number Diff line change
@@ -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<DiscountCode[]>([])
const [status, setStatus] = useState<DISCOUNTS_STATUS>(DISCOUNTS_STATUS.LOADING)

useEffect(() => {
async function fetchDiscountsData(): Promise<void> {
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 }
}
31 changes: 5 additions & 26 deletions app/pages/home/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="text-center">
<H2>{greeting}</H2>

<P className="mb-6 text-gray-600">Discover products, manage your profile, and enjoy smooth shopping!</P>

<div className="flex gap-4 flex-wrap justify-center">
<Button variant="blue" asChild>
<NavLink to={ROUTES.CATALOG}>Browse Catalog</NavLink>
</Button>

{!isAuth && (
<Button variant="gray" asChild>
<NavLink to={ROUTES.LOGIN}>Login</NavLink>
</Button>
)}
</div>
<div>
<Welcome />
<Discounts />
</div>
)
}
6 changes: 4 additions & 2 deletions app/store/cart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,7 +21,8 @@ const cart = createAppSlice({
reducers: (create) => ({
addProduct: createAddProductThunk(create),
removeProduct: createRemoveProductThunk(create),
getCart: createGetCartThunk(create)
getCart: createGetCartThunk(create),
applyCode: createApplyCodeThunk(create)
})
})

Expand All @@ -39,5 +41,5 @@ export const selectCartItems: Selector<RootState, LineItem[]> = createSelector(
export const selectIsInCart = (product: ProductProjection): Selector<RootState, boolean> =>
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
1 change: 0 additions & 1 deletion app/store/cart/reducers/addProduct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const createAddProductThunk = (

{
pending: (state) => {
state.cart = undefined
state.errorMessage = ''
state.status = CART_TABLE_STATUS.LOADING
},
Expand Down
47 changes: 47 additions & 0 deletions app/store/cart/reducers/applyCode.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<CartApi['applyDiscountCode']>>['body']

type ApplyCodeThunkPayload = {
code: Parameters<CartApi['applyDiscountCode']>[0]
}

type ApplyCodeThunkConfig = { rejectValue: string }

export const createApplyCodeThunk = (
create: ReducerCreators<CartState>
): ReturnType<typeof create.asyncThunk<ApplyCodeThunkResult, ApplyCodeThunkPayload, ApplyCodeThunkConfig>> =>
create.asyncThunk<ApplyCodeThunkResult, ApplyCodeThunkPayload, ApplyCodeThunkConfig>(
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
}
}
)
1 change: 0 additions & 1 deletion app/store/cart/reducers/removeProduct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export const createRemoveProductThunk = (

{
pending: (state) => {
state.cart = undefined
state.errorMessage = ''
state.status = CART_TABLE_STATUS.LOADING
},
Expand Down