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 { Suspense } from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
@@ -7,25 +8,35 @@ import {
ProductListSkeleton, ProductListSkeleton,
} from '@/modules/products/ui/components/product-list'; } from '@/modules/products/ui/components/product-list';
import { ProductFilters } from '@/modules/products/ui/components/product-filters'; 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 { interface Props {
params: Promise<{ params: Promise<{
category: string; category: string;
}>; }>,
searchParams: Promise<SearchParams>;
} }
const Page = async ({ params }: Props) => { const Page = async ({ params, searchParams }: Props) => {
const { category } = await params; const { category } = await params;
const queryClient = getQueryClient(); const queryClient = getQueryClient();
const filters = await loadProductFilters(searchParams);
void queryClient.prefetchQuery( void queryClient.prefetchQuery(
trpc.products.getMany.queryOptions({ trpc.products.getMany.queryOptions({
category, category,
...filters,
}) })
); );
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4"> <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="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"> <div className="lg:col-span-2 xl:col-span-2">
<ProductFilters /> <ProductFilters />
+6
View File
@@ -26,6 +26,12 @@ export const Products: CollectionConfig = {
relationTo: 'categories', relationTo: 'categories',
hasMany: false, hasMany: false,
}, },
{
name: 'tags',
type: 'relationship',
relationTo: 'tags',
hasMany: true,
},
{ {
name: 'images', name: 'images',
type: 'upload', 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 = () => { const sortValues = ['curated', 'trending', 'hot_and_new'];
return useQueryStates({
const params = {
sort: parseAsStringLiteral(sortValues).withDefault('curated'),
minPrice: parseAsString.withOptions({ minPrice: parseAsString.withOptions({
clearOnDefault: true, clearOnDefault: true
}), }).withDefault(''),
maxPrice: parseAsString.withOptions({ maxPrice: parseAsString.withOptions({
clearOnDefault: true, 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 { z } from "zod";
import { baseProcedure, createTRPCRouter } from "@/trpc/init"; import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from "@/payload-types"; import { Category } from "@/payload-types";
import { sortValues } from "../search-params";
export const productsRouter = createTRPCRouter({ export const productsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({ getMany: baseProcedure.input(z.object({
category: z.string().nullable().optional(), category: z.string().nullable().optional(),
minPrice: z.string().nullable().optional(), minPrice: z.string().nullable().optional(),
maxPrice: 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 }) => { })).query(async ({ ctx, input }) => {
const where: Where = { const where: Where = {};
price: {}, 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) { if (input.minPrice && input.maxPrice) {
@@ -63,13 +77,19 @@ export const productsRouter = createTRPCRouter({
in: [parentCategory.slug, ...subCategoriesSlugs], in: [parentCategory.slug, ...subCategoriesSlugs],
} }
} }
}
if (input.tags && input.tags.length > 0) {
where['tags.name'] = {
in: input.tags
}
} }
const data = await ctx.db.find({ const data = await ctx.db.find({
collection: 'products', collection: 'products',
depth: 1, // Populated "category" & "image" depth: 1, // Populated "category" & "image"
where, where,
sort,
}); });
return data; return data;
@@ -6,6 +6,8 @@ import { ReactNode, useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { PriceFilter } from './price-filter'; import { PriceFilter } from './price-filter';
import { TagsFilter } from './tags-filter';
import { useProductFilters } from '../../hooks/use-product-filters'; import { useProductFilters } from '../../hooks/use-product-filters';
interface ProductFiltersProps { interface ProductFiltersProps {
@@ -16,7 +18,6 @@ interface ProductFiltersProps {
const ProductFilter = ({ title, className, children }: ProductFiltersProps) => { const ProductFilter = ({ title, className, children }: ProductFiltersProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon; const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon;
return ( return (
@@ -40,17 +41,41 @@ export const ProductFilters = () => {
setFilters({ ...filters, [key]: value }); 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 ( return (
<div className="border rounded-md bg-white"> <div className="border rounded-md bg-white">
<div className="p-4 border-b flex items-center justify-between"> <div className="p-4 border-b flex items-center justify-between">
<p className="font-medium">Filters</p> <p className="font-medium">Filters</p>
<button className="underline" onClick={() => {}} type="button"> {hasAnyFilters && (
<button
className="underline cursor-pointer"
onClick={handleClearFilters}
type="button"
>
Clear Clear
</button> </button>
)}
</div> </div>
<ProductFilter title="Price" className="border-b-0"> <ProductFilter title="Price">
<PriceFilter <PriceFilter
minPrice={filters.minPrice} minPrice={filters.minPrice}
maxPrice={filters.maxPrice} maxPrice={filters.maxPrice}
@@ -58,6 +83,12 @@ export const ProductFilters = () => {
onMaxPriceChange={(value) => onChange('maxPrice', value)} onMaxPriceChange={(value) => onChange('maxPrice', value)}
/> />
</ProductFilter> </ProductFilter>
<ProductFilter title="Tags" className="border-b-0">
<TagsFilter
value={filters.tags}
onChange={(value) => onChange('tags', value)}
/>
</ProductFilter>
</div> </div>
); );
}; };
@@ -3,16 +3,20 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client'; import { useTRPC } from '@/trpc/client';
import { useProductFilters } from '../../hooks/use-product-filters';
interface Props { interface Props {
category: string; category: string;
} }
export const ProductList = ({ category }: Props) => { export const ProductList = ({ category }: Props) => {
const [filters] = useProductFilters();
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useSuspenseQuery( const { data } = useSuspenseQuery(
trpc.products.getMany.queryOptions({ trpc.products.getMany.queryOptions({
category, 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; media: Media;
categories: Category; categories: Category;
products: Product; products: Product;
tags: Tag;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@@ -85,6 +86,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>; categories: CategoriesSelect<false> | CategoriesSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>; products: ProductsSelect<false> | ProductsSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -189,11 +191,23 @@ export interface Product {
*/ */
price: number; price: number;
category?: (string | null) | Category; category?: (string | null) | Category;
tags?: (string | Tag)[] | null;
images?: (string | null) | Media; images?: (string | null) | Media;
refundPolicy?: ('30-day' | '14-day' | '7-day' | '3-day' | '1-day' | 'no-refunds') | null; refundPolicy?: ('30-day' | '14-day' | '7-day' | '3-day' | '1-day' | 'no-refunds') | null;
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
@@ -216,6 +230,10 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'products'; relationTo: 'products';
value: string | Product; value: string | Product;
} | null)
| ({
relationTo: 'tags';
value: string | Tag;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
@@ -315,11 +333,22 @@ export interface ProductsSelect<T extends boolean = true> {
description?: T; description?: T;
price?: T; price?: T;
category?: T; category?: T;
tags?: T;
images?: T; images?: T;
refundPolicy?: T; refundPolicy?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select". * 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 { Media } from './collections/Media'
import { Categories } from './collections/Categories' import { Categories } from './collections/Categories'
import { Products } from './collections/Products' import { Products } from './collections/Products'
import { Tags } from './collections/Tags'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -23,7 +24,7 @@ export default buildConfig({
}, },
}, },
cookiePrefix: 'funroad', cookiePrefix: 'funroad',
collections: [Users, Media, Categories, Products], collections: [Users, Media, Categories, Products, Tags],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '', secret: process.env.PAYLOAD_SECRET || '',
typescript: { typescript: {
+2
View File
@@ -1,11 +1,13 @@
import { authRouter } from "@/modules/auth/server/procedures"; import { authRouter } from "@/modules/auth/server/procedures";
import { categoriesRouter } from '@/modules/categories/server/procedures'; import { categoriesRouter } from '@/modules/categories/server/procedures';
import { productsRouter } from "@/modules/products/server/procedures"; import { productsRouter } from "@/modules/products/server/procedures";
import { tagsRouter } from "@/modules/tags/server/procedures";
import { createTRPCRouter } from '../init'; import { createTRPCRouter } from '../init';
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
auth: authRouter, auth: authRouter,
tags: tagsRouter,
products: productsRouter, products: productsRouter,
categories: categoriesRouter, categories: categoriesRouter,
}); });