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
+13 -2
View File
@@ -1,3 +1,4 @@
import type { SearchParams } from 'nuqs/server';
import { Suspense } from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
@@ -7,25 +8,35 @@ import {
ProductListSkeleton,
} from '@/modules/products/ui/components/product-list';
import { ProductFilters } from '@/modules/products/ui/components/product-filters';
import { loadProductFilters } from '@/modules/products/search-params';
import { ProductSort } from '@/modules/products/ui/components/product-sort';
interface Props {
params: Promise<{
category: string;
}>;
}>,
searchParams: Promise<SearchParams>;
}
const Page = async ({ params }: Props) => {
const Page = async ({ params, searchParams }: Props) => {
const { category } = await params;
const queryClient = getQueryClient();
const filters = await loadProductFilters(searchParams);
void queryClient.prefetchQuery(
trpc.products.getMany.queryOptions({
category,
...filters,
})
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4">
<div className="flex flex-col lg:flex-row lg:items-center gap-y-2 lg:gap-y-0 justify-between">
<p className='text-2xl font-medium'>Curated for you</p>
<ProductSort />
</div>
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-8 gap-y-6 gap-x-12">
<div className="lg:col-span-2 xl:col-span-2">
<ProductFilters />
+6
View File
@@ -26,6 +26,12 @@ export const Products: CollectionConfig = {
relationTo: 'categories',
hasMany: false,
},
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
{
name: 'images',
type: 'upload',
+25
View File
@@ -0,0 +1,25 @@
import { CollectionConfig } from "payload";
export const Tags: CollectionConfig = {
slug: "tags",
access: {
read: () => true,
},
admin: {
useAsTitle: 'name',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
unique: true,
},
{
name: 'product',
type: 'relationship',
relationTo: 'products',
hasMany: true,
}
]
};
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_LIMIT = 8;
@@ -1,12 +1,20 @@
import { parseAsString, useQueryStates } from 'nuqs';
import { useQueryStates, parseAsString, parseAsArrayOf, parseAsStringLiteral } from 'nuqs';
export const useProductFilters = () => {
return useQueryStates({
const sortValues = ['curated', 'trending', 'hot_and_new'];
const params = {
sort: parseAsStringLiteral(sortValues).withDefault('curated'),
minPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
clearOnDefault: true
}).withDefault(''),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
})
}).withDefault(''),
tags: parseAsArrayOf(parseAsString).withOptions({
clearOnDefault: true,
}).withDefault([])
}
export const useProductFilters = () => {
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">
{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;
})
})
+29
View File
@@ -71,6 +71,7 @@ export interface Config {
media: Media;
categories: Category;
products: Product;
tags: Tag;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -85,6 +86,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -189,11 +191,23 @@ export interface Product {
*/
price: number;
category?: (string | null) | Category;
tags?: (string | Tag)[] | null;
images?: (string | null) | Media;
refundPolicy?: ('30-day' | '14-day' | '7-day' | '3-day' | '1-day' | 'no-refunds') | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags".
*/
export interface Tag {
id: string;
name: string;
product?: (string | Product)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -216,6 +230,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'products';
value: string | Product;
} | null)
| ({
relationTo: 'tags';
value: string | Tag;
} | null);
globalSlug?: string | null;
user: {
@@ -315,11 +333,22 @@ export interface ProductsSelect<T extends boolean = true> {
description?: T;
price?: T;
category?: T;
tags?: T;
images?: T;
refundPolicy?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tags_select".
*/
export interface TagsSelect<T extends boolean = true> {
name?: T;
product?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
+2 -1
View File
@@ -11,6 +11,7 @@ import { Users } from './collections/Users';
import { Media } from './collections/Media'
import { Categories } from './collections/Categories'
import { Products } from './collections/Products'
import { Tags } from './collections/Tags'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -23,7 +24,7 @@ export default buildConfig({
},
},
cookiePrefix: 'funroad',
collections: [Users, Media, Categories, Products],
collections: [Users, Media, Categories, Products, Tags],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
+2
View File
@@ -1,11 +1,13 @@
import { authRouter } from "@/modules/auth/server/procedures";
import { categoriesRouter } from '@/modules/categories/server/procedures';
import { productsRouter } from "@/modules/products/server/procedures";
import { tagsRouter } from "@/modules/tags/server/procedures";
import { createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({
auth: authRouter,
tags: tagsRouter,
products: productsRouter,
categories: categoriesRouter,
});