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
2 changes: 1 addition & 1 deletion app/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class CtpApiClient {
body: {
email,
password,
activeCartSignInMode: 'MergeWithExistingCustomerCart',
activeCartSignInMode: 'UseAsNewActiveCustomerCart',
updateProductData: true
}
})
Expand Down
32 changes: 22 additions & 10 deletions 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, type ProductProjection } from '@commercetools/platform-sdk'
import { type Cart, type ClientResponse } from '@commercetools/platform-sdk'

type CartApiProperties = {
client: CtpApiClient
Expand All @@ -15,23 +15,23 @@ export class CartApi {
this.client = client
}

public async addProduct(product: ProductProjection, quantity: number = 1): Promise<ClientResponse<Cart>> {
public async addProduct(productId: string, quantity: number = 1): Promise<ClientResponse<Cart>> {
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<ClientResponse<Cart>> {
public async removeProduct(productId: string, quantity: number = 1): Promise<ClientResponse<Cart>> {
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
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion app/api/namespaces/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/hooks/useFetchCart.ts
Original file line number Diff line number Diff line change
@@ -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()), [])
}
76 changes: 53 additions & 23 deletions app/pages/cart/index.tsx
Original file line number Diff line number Diff line change
@@ -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<void> => {
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 <EmptyBasket />
}

// TODO: move to separate component
const handleClick = async (
event: MouseEvent<HTMLAnchorElement>,
productId: string,
quantity: number
): Promise<void> => {
event.preventDefault()
await dispatch(removeProduct({ productId, quantity })).unwrap()
}

// TODO: fetch products by id to get pictures
return (
<div>
<EmptyBasket />
</div>
<>
{cartItems.map(({ name, productId, quantity, price, totalPrice }) => (
<div key={productId} className="flex gap-5">
<div>
{name['en-US']} (amount: {quantity})
</div>

<ProductPrice startPrice={price.value.centAmount} discountPrice={price.discounted?.value?.centAmount} />

{quantity > 1 && <div>Price of all items: {formatProductItemPrice(totalPrice.centAmount)}</div>}

{status === CART_TABLE_STATUS.LOADING ? (
// TODO: add some loader
<>...</>
) : (
<a href="#" onClick={(event) => void handleClick(event, productId, quantity)}>
remove
</a>
)}
</div>
))}
</>
)
}
55 changes: 39 additions & 16 deletions app/pages/catalog/AddToCartButton.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false)

const handleClick = async (event: MouseEvent<HTMLButtonElement>): Promise<void> => {
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 (
<div onClick={(event) => event.stopPropagation()} className={isProductInCart ? 'cursor-auto' : 'cursor-pointer'}>
<Button
variant={isProductInCart ? 'secondary' : 'outline'}
size="icon"
className="relative cursor-pointer disabled:opacity-100"
disabled={isProductInCart}
>
<ShoppingCart size={16} />
{isProductInCart && <Check className="text-green-400 absolute bottom-0 right-0" />}
</Button>
</div>
<Button
variant={isProductInCart ? 'secondary' : 'outline'}
size="icon"
className={cn(
'relative cursor-pointer disabled:opacity-100',
isProductInCart ? 'cursor-not-allowed' : 'cursor-pointer'
)}
disabled={isProductInCart}
onClick={(event) => void handleClick(event)}
>
<ShoppingCart size={16} />
{isProductInCart && <Check className="text-green-400 absolute bottom-0 right-0" />}
</Button>
)
}
4 changes: 2 additions & 2 deletions app/pages/catalog/FilterForm/FilterFormBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -170,7 +170,7 @@ export function FilterFormBody({ filters, categories, fetch, onApply }: FilterFo
const handleApply = (data: FormValues): Promise<void> => {
onApply()
return fetch(
{ limit: ITEMS_PER_PAGE },
{ limit: PRODUCT_LIST_ITEMS_PER_PAGE },
convertFormValuesToAppliedFilters(data, filters),
convertFormValuesToSort(data, sorts)
)
Expand Down
2 changes: 1 addition & 1 deletion app/pages/catalog/ProductItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function ProductItem({ product }: ProductItemProperties): ReactElement {
<CardDescription className="flex-1 line-clamp-2">{description}</CardDescription>
<div className="flex justify-between items-center">
<ProductPrice startPrice={price} discountPrice={discountPrice} />
<AddToCartButton productId={product.id} />
<AddToCartButton product={product} />
</div>
</CardContent>
</Card>
Expand Down
4 changes: 2 additions & 2 deletions app/pages/catalog/ProductList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -17,7 +17,7 @@ export function ProductList({ products, status }: ProductListProperties): ReactE
<div className="flex gap-3 flex-wrap px-2 pt-1 pb-3">
{isNotReady
? // TODO: move to separate component
Array.from({ length: ITEMS_PER_PAGE }).map((_, index) => (
Array.from({ length: PRODUCT_LIST_ITEMS_PER_PAGE }).map((_, index) => (
<Skeleton className="w-2xs aspect-[3/4] mx-auto" key={index} />
))
: products.map((product) => <ProductItem product={product} key={product.id} />)}
Expand Down
4 changes: 2 additions & 2 deletions app/pages/catalog/SearchForm/SearchFomBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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 (
Expand Down
39 changes: 33 additions & 6 deletions app/pages/catalog/hooks/useCatalogData.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<SetStateAction<number>>
setSearchText: Dispatch<SetStateAction<string>>
status: CATALOG_STATUS
total: number
}

export function useCatalogData(): UseCatalogDataResult {
const [status, setStatus] = useState<CATALOG_STATUS>(CATALOG_STATUS.LOADING)

const [currentPage, setCurrentPage] = useState<number>(1)
const [searchText, setSearchText] = useState<string>('')
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
}
}
Loading