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 3 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
133 changes: 133 additions & 0 deletions app/components/ui/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeft, ChevronsRight, MoreHorizontalIcon } from 'lucide-react'

import { cn } from '~/utils/ui'
import type { Button } from '~/components/ui/Button'
import { buttonVariants } from '~/components/ui/Button'
import type { ComponentProps, ReactElement } from 'react'

function Pagination({ className, ...properties }: ComponentProps<'nav'>): ReactElement {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center my-4', className)}
{...properties}
/>
)
}

function PaginationContent({ className, ...properties }: ComponentProps<'ul'>): ReactElement {
return (
<ul data-slot="pagination-content" className={cn('flex flex-row items-center gap-1', className)} {...properties} />
)
}

function PaginationItem({ ...properties }: ComponentProps<'li'>): ReactElement {
return <li data-slot="pagination-item" {...properties} />
}

type PaginationLinkProperties = {
isActive?: boolean
} & Pick<ComponentProps<typeof Button>, 'size'> &
ComponentProps<'a'>

function PaginationLink({ className, isActive, size = 'icon', ...properties }: PaginationLinkProperties): ReactElement {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size
}),
className
)}
{...properties}
/>
)
}

function PaginationPrevious({ className, ...properties }: ComponentProps<typeof PaginationLink>): ReactElement {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...properties}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}

function PaginationNext({ className, ...properties }: ComponentProps<typeof PaginationLink>): ReactElement {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...properties}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}

function PaginationStart({ className, ...properties }: ComponentProps<typeof PaginationLink>): ReactElement {
return (
<PaginationLink
aria-label="Go to first page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...properties}
>
<ChevronsLeft />
<span className="hidden sm:block">Start</span>
</PaginationLink>
)
}

function PaginationEnd({ className, ...properties }: ComponentProps<typeof PaginationLink>): ReactElement {
return (
<PaginationLink
aria-label="Go to last page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...properties}
>
<span className="hidden sm:block">End</span>
<ChevronsRight />
</PaginationLink>
)
}

function PaginationEllipsis({ className, ...properties }: ComponentProps<'span'>): ReactElement {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...properties}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}

export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
PaginationStart,
PaginationEnd
}
13 changes: 8 additions & 5 deletions app/pages/catalog/FilterForm/Categories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ import { ROUTES } from '~/routes'

type LinkProperties = {
category: ProductListCategory
onClick: () => void
}

type CategoriesProperties = {
categories: ProductListCategory[]
onClick: () => void
}

function Link({ category }: LinkProperties): ReactElement {
function Link({ category, onClick }: LinkProperties): ReactElement {
const navigate = useNavigate()
const { categoryId } = useParams()

const handleClick = async (event: MouseEvent, categoryId: string): Promise<void> => {
event.preventDefault()
onClick()
return navigate(generatePath(ROUTES.CATEGORY, { categoryId }))
}

Expand All @@ -35,7 +38,7 @@ function Link({ category }: LinkProperties): ReactElement {
)
}

export function Categories({ categories }: CategoriesProperties): ReactElement {
export function Categories({ categories, onClick }: CategoriesProperties): ReactElement {
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(categories.map((category) => [category.id, true]))
)
Expand All @@ -52,7 +55,7 @@ export function Categories({ categories }: CategoriesProperties): ReactElement {
onOpenChange={() => setOpen((previous) => ({ ...previous, [category.id]: !previous[category.id] }))}
>
<div className="flex justify-between items-center cursor-pointer">
<Link category={category} />
<Link category={category} onClick={onClick} />
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-8 cursor-pointer">
<ChevronsUpDown />
Expand All @@ -61,12 +64,12 @@ export function Categories({ categories }: CategoriesProperties): ReactElement {
</div>
<CollapsibleContent>
<div className="pl-3">
<Categories categories={category.subCategories} />
<Categories categories={category.subCategories} onClick={onClick} />
</div>
</CollapsibleContent>
</Collapsible>
) : (
<Link category={category} />
<Link category={category} onClick={onClick} />
)}
</li>
))}
Expand Down
11 changes: 7 additions & 4 deletions app/pages/catalog/FilterForm/FilterFormBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type FilterFormBodyProperties = {
filters: ProductListFilter[]
categories: ProductListCategory[]
fetch: UseCatalogDataResult['fetchProducts']
onApply: () => void
}

const SORT_KEY_PREFIX = 'sort_'
Expand Down Expand Up @@ -161,16 +162,18 @@ function FilterFormFields({
)
}

export function FilterFormBody({ filters, categories, fetch }: FilterFormBodyProperties): ReactElement {
export function FilterFormBody({ filters, categories, fetch, onApply }: FilterFormBodyProperties): ReactElement {
const defaultValues = getDefaultFormValues(filters, sorts)
const form = useForm<FormValues>({ defaultValues })

const handleApply = (data: FormValues): Promise<void> =>
fetch(
const handleApply = (data: FormValues): Promise<void> => {
onApply()
return fetch(
{ limit: PRODUCTS_LIMIT },
convertFormValuesToAppliedFilters(data, filters),
convertFormValuesToSort(data, sorts)
)
}

const handleReset = (): Promise<void> => {
form.reset(defaultValues)
Expand All @@ -182,7 +185,7 @@ export function FilterFormBody({ filters, categories, fetch }: FilterFormBodyPro
<Sidebar>
<SidebarContent className="p-4">
<form onSubmit={(event) => void form.handleSubmit(handleApply)(event)} className="space-y-4">
<Categories categories={categories} />
<Categories categories={categories} onClick={onApply} />
<FilterFormFields filters={filters} form={form} />
<SidebarGroup>
<div className="flex justify-between">
Expand Down
13 changes: 13 additions & 0 deletions app/pages/catalog/NoProductsFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Sparkles } from 'lucide-react'
import type { ReactElement } from 'react'

export function NoProductsFound(): ReactElement {
return (
<div className="w-full h-full flex items-center justify-center">
<div className="max-w-xs flex flex-col items-center justify-center gap-4 text-center">
<Sparkles size={60} className="text-sky-200" />
<p className="text-sm">We couldn’t find any products matching your search.</p>
</div>
</div>
)
}
58 changes: 58 additions & 0 deletions app/pages/catalog/PaginationControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { ReactElement } from 'react'
import {
Pagination,
PaginationContent,
PaginationEnd,
PaginationItem,
PaginationNext,
PaginationPrevious,
PaginationStart
} from '~/components/ui/Pagination'

export const ITEMS_PER_PAGE = 12

type PaginationControlsProperties = {
page: number
totalPage: number
onPageChange: (page: number) => void
}

export function PaginationControls({ page, totalPage, onPageChange }: PaginationControlsProperties): ReactElement {
const isFirstPage = page === 1
const isLastPage = page === totalPage
const disabledState = 'pointer-events-none opacity-50'

return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationStart
onClick={() => !isFirstPage && onPageChange(1)}
className={isFirstPage ? disabledState : ''}
/>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={() => !isFirstPage && onPageChange(page - 1)}
className={isFirstPage ? disabledState : ''}
/>
</PaginationItem>
<PaginationItem className="px-2.5 sm:pl-2.5 border rounded-sm">
{page} / {totalPage}
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => !isLastPage && onPageChange(page + 1)}
className={isLastPage ? disabledState : ''}
/>
</PaginationItem>
<PaginationItem>
<PaginationEnd
onClick={() => !isLastPage && onPageChange(totalPage)}
className={isLastPage ? disabledState : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
28 changes: 23 additions & 5 deletions app/pages/catalog/ProductItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type ProductProjection } from '@commercetools/platform-sdk'
import { type ReactElement } from 'react'
import { generatePath, useNavigate } from 'react-router'
import { Card, CardContent, CardDescription, CardTitle } from '~/components/ui/Card'
import { ShoppingCart } from 'lucide-react'
import { Check, ShoppingCart } from 'lucide-react'
import { Button } from '~/components/ui/Button'
import { SaleBadge } from '~/components/product/SaleBadge'
import { AspectRatio } from '~/components/ui/AspectRatio'
Expand All @@ -26,7 +26,7 @@ export function ProductItem({ product }: ProductItemProperties): ReactElement {

return (
<Card
className="w-full m-0 max-w-2xs aspect-[3/4] hover:scale-105 hover:shadow-xl/30 transition duration-300 cursor-pointer hover:bg-stone-50"
className="w-full m-0 max-w-2xs aspect-[3/4] hover:scale-105 hover:shadow-xl/30 transition duration-300 hover:bg-stone-50"
onClick={() => void navigateTo(product.id)}
>
<CardContent className="space-y-0 h-full flex flex-col justify-between gap-y-2 relative">
Expand All @@ -39,11 +39,29 @@ 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} />
<Button variant="outline" size="icon">
<ShoppingCart size={16} />
</Button>
<AddToCartButton productId={product.id} />
</div>
</CardContent>
</Card>
)
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function AddToCartButton({ productId }: { productId: string }): ReactElement {
// TODO:
const isProductInCart = false

return (
<div onClick={(event) => event.stopPropagation()}>
<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>
)
}
20 changes: 2 additions & 18 deletions app/pages/catalog/ProductList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +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 { Sparkles } from 'lucide-react'

// TODO: items per page
const SKELETON_COUNT = 8
import { ITEMS_PER_PAGE } from './PaginationControls'

type ProductListProperties = {
products: ProductProjection[]
Expand All @@ -15,25 +12,12 @@ type ProductListProperties = {

export function ProductList({ products, status }: ProductListProperties): ReactElement {
const isNotReady = status !== CATALOG_STATUS.READY
const isEmpty = !isNotReady && products.length === 0

if (isEmpty) {
// TODO: move to separate component
return (
<div className="w-full h-full flex items-center justify-center">
<div className="max-w-xs flex flex-col items-center justify-center gap-4 text-center">
<Sparkles size={60} className="text-sky-200" />
<p className="text-sm">We couldn’t find any products matching your search.</p>
</div>
</div>
)
}

return (
<div className="flex gap-3 flex-wrap px-2 pt-1 pb-3">
{isNotReady
? // TODO: move to separate component
Array.from({ length: SKELETON_COUNT }).map((_, index) => (
Array.from({ length: 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
Loading