feat: implement filter feature

This commit is contained in:
2025-07-26 10:43:08 +07:00
parent 30b771d8ed
commit a43aa5a4e8
15 changed files with 319 additions and 20 deletions
@@ -1,12 +1,20 @@
import { parseAsString, useQueryStates } from 'nuqs';
import { useQueryStates, parseAsString, parseAsArrayOf, parseAsStringLiteral } from 'nuqs';
const sortValues = ['curated', 'trending', 'hot_and_new'];
const params = {
sort: parseAsStringLiteral(sortValues).withDefault('curated'),
minPrice: parseAsString.withOptions({
clearOnDefault: true
}).withDefault(''),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}).withDefault(''),
tags: parseAsArrayOf(parseAsString).withOptions({
clearOnDefault: true,
}).withDefault([])
}
export const useProductFilters = () => {
return useQueryStates({
minPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
})
return useQueryStates(params);
}
+18
View File
@@ -0,0 +1,18 @@
import { createLoader, parseAsString, parseAsArrayOf, parseAsStringLiteral } from 'nuqs/server';
export const sortValues = ['curated', 'trending', 'hot_and_new'] as const;
const params = {
sort: parseAsStringLiteral(sortValues).withDefault('curated'),
minPrice: parseAsString.withOptions({
clearOnDefault: true
}).withDefault(''),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}).withDefault(''),
tags: parseAsArrayOf(parseAsString).withOptions({
clearOnDefault: true,
}).withDefault([])
}
export const loadProductFilters = createLoader(params);
+23 -3
View File
@@ -1,17 +1,31 @@
import type { Where } from "payload";
import type { Sort, Where } from "payload";
import { z } from "zod";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from "@/payload-types";
import { sortValues } from "../search-params";
export const productsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({
category: z.string().nullable().optional(),
minPrice: z.string().nullable().optional(),
maxPrice: z.string().nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
sort: z.enum(sortValues).nullable().optional(),
})).query(async ({ ctx, input }) => {
const where: Where = {
price: {},
const where: Where = {};
let sort: Sort = "-createdAt";
if (input.sort === 'curated') {
sort = '-createdAt';
}
if (input.sort === 'hot_and_new') {
sort = '+createdAt';
}
if (input.sort === 'trending') {
sort = '-createdAt';
}
if (input.minPrice && input.maxPrice) {
@@ -63,13 +77,19 @@ export const productsRouter = createTRPCRouter({
in: [parentCategory.slug, ...subCategoriesSlugs],
}
}
}
if (input.tags && input.tags.length > 0) {
where['tags.name'] = {
in: input.tags
}
}
const data = await ctx.db.find({
collection: 'products',
depth: 1, // Populated "category" & "image"
where,
sort,
});
return data;
@@ -6,6 +6,8 @@ import { ReactNode, useState } from 'react';
import { cn } from '@/lib/utils';
import { PriceFilter } from './price-filter';
import { TagsFilter } from './tags-filter';
import { useProductFilters } from '../../hooks/use-product-filters';
interface ProductFiltersProps {
@@ -16,7 +18,6 @@ interface ProductFiltersProps {
const ProductFilter = ({ title, className, children }: ProductFiltersProps) => {
const [isOpen, setIsOpen] = useState(false);
const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon;
return (
@@ -40,17 +41,41 @@ export const ProductFilters = () => {
setFilters({ ...filters, [key]: value });
};
const hasAnyFilters = Object.entries(filters).some(([key, value]) => {
if(key === 'sort') return false;
if(Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === 'string') {
return value.trim() !== '';
}
return value !== null;
});
const handleClearFilters = () => {
setFilters({ minPrice: '', maxPrice: '', tags: [] });
};
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>
{hasAnyFilters && (
<button
className="underline cursor-pointer"
onClick={handleClearFilters}
type="button"
>
Clear
</button>
)}
</div>
<ProductFilter title="Price" className="border-b-0">
<ProductFilter title="Price">
<PriceFilter
minPrice={filters.minPrice}
maxPrice={filters.maxPrice}
@@ -58,6 +83,12 @@ export const ProductFilters = () => {
onMaxPriceChange={(value) => onChange('maxPrice', value)}
/>
</ProductFilter>
<ProductFilter title="Tags" className="border-b-0">
<TagsFilter
value={filters.tags}
onChange={(value) => onChange('tags', value)}
/>
</ProductFilter>
</div>
);
};
@@ -3,16 +3,20 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
import { useProductFilters } from '../../hooks/use-product-filters';
interface Props {
category: string;
}
export const ProductList = ({ category }: Props) => {
const [filters] = useProductFilters();
const trpc = useTRPC();
const { data } = useSuspenseQuery(
trpc.products.getMany.queryOptions({
category,
...filters,
})
);
@@ -0,0 +1,54 @@
'use client';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useProductFilters } from '../../hooks/use-product-filters';
export const ProductSort = () => {
const [filters, setFilters] = useProductFilters();
return (
<div className="flex items-center gap-2">
<Button
size="sm"
className={cn(
'rounded-full bg-white hover:bg-white',
filters.sort !== 'curated' &&
'bg-transparent border-transparent hover:border-border hover:bg-transparent'
)}
variant="secondary"
onClick={() => setFilters({ sort: 'curated' })}
>
Curated
</Button>
<Button
size="sm"
className={cn(
'rounded-full bg-white hover:bg-white',
filters.sort !== 'trending' &&
'bg-transparent border-transparent hover:border-border hover:bg-transparent'
)}
variant="secondary"
onClick={() => setFilters({ sort: 'trending' })}
>
Trending
</Button>
<Button
size="sm"
className={cn(
'rounded-full bg-white hover:bg-white',
filters.sort !== 'hot_and_new' &&
'bg-transparent border-transparent hover:border-border hover:bg-transparent'
)}
variant="secondary"
onClick={() => setFilters({ sort: 'hot_and_new' })}
>
Hot & New
</Button>
</div>
);
};
@@ -0,0 +1,69 @@
import { LoaderIcon } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { DEFAULT_LIMIT } from '@/constants';
import { useTRPC } from '@/trpc/client';
import { Checkbox } from '@/components/ui/checkbox';
interface TagsFilterProps {
value?: string[] | null;
onChange: (value: string[]) => void;
}
export const TagsFilter = ({ value, onChange }: TagsFilterProps) => {
const trpc = useTRPC();
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(
trpc.tags.getMany.infiniteQueryOptions(
{ limit: DEFAULT_LIMIT },
{
getNextPageParam: (lastPage) =>
lastPage.docs.length > 0 ? lastPage.nextPage : undefined,
}
)
);
const handleClickTag = (tag: string) => {
if (value?.includes(tag)) {
onChange(value?.filter((t) => t !== tag) || []);
} else {
onChange([...(value || []), tag]);
}
};
return (
<div className="flex flex-col gap-2">
{isLoading ? (
<div className="flex items-center justify-center p-4">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : (
data?.pages.map((page) =>
page.docs.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between cursor-pointer"
onClick={() => handleClickTag(tag.name)}
>
<p className="font-medium">{tag.name}</p>
<Checkbox
checked={value?.includes(tag.name)}
onCheckedChange={() => handleClickTag(tag.name)}
/>
</div>
))
)
)}
{hasNextPage && (
<button
disabled={isFetchingNextPage}
onClick={() => fetchNextPage()}
className="underline font-medium justify-start text-start disabled:opacity-50 cursor-pointer"
>
Load more...
</button>
)}
</div>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
import { DEFAULT_LIMIT } from "@/constants";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
export const tagsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({
cursor: z.number().default(1),
limit: z.number().default(DEFAULT_LIMIT),
})).query(async ({ ctx, input }) => {
const data = await ctx.db.find({
collection: 'tags',
page: input.cursor,
limit: input.limit,
});
return data;
})
})