feat: implement filtering for the product

This commit is contained in:
2025-07-21 21:32:40 +07:00
parent c9248b0ba4
commit 7732e680d4
11 changed files with 233 additions and 30 deletions
@@ -20,7 +20,6 @@ export const categoriesRouter = createTRPCRouter({
...category,
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
...(doc as Category),
subcategories: undefined,
})),
}));
@@ -0,0 +1,12 @@
import { parseAsString, useQueryStates } from 'nuqs';
export const useProductFilters = () => {
return useQueryStates({
minPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
})
}
+23 -4
View File
@@ -1,13 +1,33 @@
import type { Where } from "payload";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { z } from "zod";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from "@/payload-types";
export const productsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({
category: z.string().nullable().optional(),
minPrice: z.string().nullable().optional(),
maxPrice: z.string().nullable().optional(),
})).query(async ({ ctx, input }) => {
const where: Where = {}
const where: Where = {
price: {},
}
if (input.minPrice && input.maxPrice) {
where.price = {
greater_than_equal: input.minPrice,
less_than_equal: input.maxPrice,
}
} else if (input.minPrice) {
where.price = {
greater_than_equal: input.minPrice,
}
} else if (input.maxPrice) {
where.price = {
less_than_equal: input.maxPrice,
}
}
if (input.category) {
const categoriesData = await ctx.db.find({
@@ -38,13 +58,12 @@ export const productsRouter = createTRPCRouter({
subCategoriesSlugs.push(
...parentCategory.subcategories.map((subcategory) => subcategory.slug)
);
}
if (parentCategory) {
where['category.slug'] = {
in: [parentCategory.slug, ...subCategoriesSlugs],
}
}
}
const data = await ctx.db.find({
@@ -0,0 +1,80 @@
import { ChangeEvent } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
minPrice?: string | null;
maxPrice?: string | null;
onMinPriceChange?: (value: string) => void;
onMaxPriceChange?: (value: string) => void;
}
export const formatAsCurrency = (value: string) => {
const numericValue = value.replace(/[^0-9.]/g, '');
const parts = numericValue.split('.');
const formattedValue =
parts[0] + (parts.length > 1 ? '.' + parts[1]?.slice(0, 2) : '');
if (!formattedValue) return '';
const numberValue = parseFloat(formattedValue);
if (isNaN(numberValue)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(numberValue);
};
export const PriceFilter = ({
minPrice,
maxPrice,
onMinPriceChange,
onMaxPriceChange,
}: Props) => {
const handleMinPriceChange = (event: ChangeEvent<HTMLInputElement>) => {
const numericValue = event.target.value.replace(/[^0-9.]/g, '');
onMinPriceChange?.(numericValue);
}
const handleMaxPriceChange = (event: ChangeEvent<HTMLInputElement>) => {
const numericValue = event.target.value.replace(/[^0-9.]/g, '');
onMaxPriceChange?.(numericValue);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Label className="font-medium text-base">
Minimum price
</Label>
<Input
type="text"
placeholder="$0"
value={minPrice ? formatAsCurrency(minPrice) : ''}
onChange={handleMinPriceChange}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="font-medium text-base">
Maximum price
</Label>
<Input
type="text"
placeholder="∞"
value={maxPrice ? formatAsCurrency(maxPrice) : ''}
onChange={handleMaxPriceChange}
/>
</div>
</div>
);
};
@@ -0,0 +1,63 @@
'use client';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { cn } from '@/lib/utils';
import { PriceFilter } from './price-filter';
import { useProductFilters } from '../../hooks/use-product-filters';
interface ProductFiltersProps {
title: string;
className?: string;
children: ReactNode;
}
const ProductFilter = ({ title, className, children }: ProductFiltersProps) => {
const [isOpen, setIsOpen] = useState(false);
const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon;
return (
<div className={cn('p-4 border-b flex flex-col gap-2', className)}>
<div
onClick={() => setIsOpen((current) => !current)}
className="flex items-center justify-between cursor-pointer"
>
<p className="font-medium">{title}</p>
<Icon className="size-5" />
</div>
{isOpen && children}
</div>
);
};
export const ProductFilters = () => {
const [filters, setFilters] = useProductFilters();
const onChange = (key: keyof typeof filters, value: unknown) => {
setFilters({ ...filters, [key]: value });
};
return (
<div className="border rounded-md bg-white">
<div className="p-4 border-b flex items-center justify-between">
<p className="font-medium">Filters</p>
<button className="underline" onClick={() => {}} type="button">
Clear
</button>
</div>
<ProductFilter title="Price" className="border-b-0">
<PriceFilter
minPrice={filters.minPrice}
maxPrice={filters.maxPrice}
onMinPriceChange={(value) => onChange('minPrice', value)}
onMaxPriceChange={(value) => onChange('maxPrice', value)}
/>
</ProductFilter>
</div>
);
};
@@ -8,14 +8,24 @@ interface Props {
category: string;
}
export const ProductList = ({ category }: Props) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.products.getMany.queryOptions({
category
}));
const { data } = useSuspenseQuery(
trpc.products.getMany.queryOptions({
category,
})
);
return <div>{JSON.stringify(data)}</div>;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{data?.docs.map((product) => (
<div key={product.id} className="border rounded-md bg-white p-4">
<h2 className="text-xl font-medium">{product.name}</h2>
<p>${product.price}</p>
</div>
))}
</div>
);
};
export const ProductListSkeleton = () => {