From a43aa5a4e89fc813b7fbdf11d5709eb4eba2c5e7 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Sat, 26 Jul 2025 10:43:08 +0700 Subject: [PATCH] feat: implement filter feature --- src/app/(app)/(home)/[category]/page.tsx | 15 +++- src/collections/Products.ts | 6 ++ src/collections/Tags.ts | 25 +++++++ src/constants.ts | 1 + .../products/hooks/use-product-filters.ts | 26 ++++--- src/modules/products/search-params.ts | 18 +++++ src/modules/products/server/procedures.ts | 26 ++++++- .../ui/components/product-filters.tsx | 41 +++++++++-- .../products/ui/components/product-list.tsx | 4 ++ .../products/ui/components/product-sort.tsx | 54 +++++++++++++++ .../products/ui/components/tags-filter.tsx | 69 +++++++++++++++++++ src/modules/tags/server/procedures.ts | 20 ++++++ src/payload-types.ts | 29 ++++++++ src/payload.config.ts | 3 +- src/trpc/routers/_app.ts | 2 + 15 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 src/collections/Tags.ts create mode 100644 src/constants.ts create mode 100644 src/modules/products/search-params.ts create mode 100644 src/modules/products/ui/components/product-sort.tsx create mode 100644 src/modules/products/ui/components/tags-filter.tsx create mode 100644 src/modules/tags/server/procedures.ts diff --git a/src/app/(app)/(home)/[category]/page.tsx b/src/app/(app)/(home)/[category]/page.tsx index 82e76b0..6944eae 100644 --- a/src/app/(app)/(home)/[category]/page.tsx +++ b/src/app/(app)/(home)/[category]/page.tsx @@ -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; } -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 (
+
+

Curated for you

+ +
diff --git a/src/collections/Products.ts b/src/collections/Products.ts index 239a7a2..34e46f8 100644 --- a/src/collections/Products.ts +++ b/src/collections/Products.ts @@ -26,6 +26,12 @@ export const Products: CollectionConfig = { relationTo: 'categories', hasMany: false, }, + { + name: 'tags', + type: 'relationship', + relationTo: 'tags', + hasMany: true, + }, { name: 'images', type: 'upload', diff --git a/src/collections/Tags.ts b/src/collections/Tags.ts new file mode 100644 index 0000000..a47e8f3 --- /dev/null +++ b/src/collections/Tags.ts @@ -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, + } + ] +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..dea4d76 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_LIMIT = 8; \ No newline at end of file diff --git a/src/modules/products/hooks/use-product-filters.ts b/src/modules/products/hooks/use-product-filters.ts index 27de10b..6ae0573 100644 --- a/src/modules/products/hooks/use-product-filters.ts +++ b/src/modules/products/hooks/use-product-filters.ts @@ -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); } \ No newline at end of file diff --git a/src/modules/products/search-params.ts b/src/modules/products/search-params.ts new file mode 100644 index 0000000..b5dfcbd --- /dev/null +++ b/src/modules/products/search-params.ts @@ -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); \ No newline at end of file diff --git a/src/modules/products/server/procedures.ts b/src/modules/products/server/procedures.ts index edc7295..7364e05 100644 --- a/src/modules/products/server/procedures.ts +++ b/src/modules/products/server/procedures.ts @@ -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; diff --git a/src/modules/products/ui/components/product-filters.tsx b/src/modules/products/ui/components/product-filters.tsx index dcf5f07..7ad6d42 100644 --- a/src/modules/products/ui/components/product-filters.tsx +++ b/src/modules/products/ui/components/product-filters.tsx @@ -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 (

Filters

- + {hasAnyFilters && ( + + )}
- + { onMaxPriceChange={(value) => onChange('maxPrice', value)} /> + + onChange('tags', value)} + /> +
); }; diff --git a/src/modules/products/ui/components/product-list.tsx b/src/modules/products/ui/components/product-list.tsx index 6ce559e..cfec877 100644 --- a/src/modules/products/ui/components/product-list.tsx +++ b/src/modules/products/ui/components/product-list.tsx @@ -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, }) ); diff --git a/src/modules/products/ui/components/product-sort.tsx b/src/modules/products/ui/components/product-sort.tsx new file mode 100644 index 0000000..aab00c1 --- /dev/null +++ b/src/modules/products/ui/components/product-sort.tsx @@ -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 ( +
+ + + + + +
+ ); +}; diff --git a/src/modules/products/ui/components/tags-filter.tsx b/src/modules/products/ui/components/tags-filter.tsx new file mode 100644 index 0000000..8bf24de --- /dev/null +++ b/src/modules/products/ui/components/tags-filter.tsx @@ -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 ( +
+ {isLoading ? ( +
+ +
+ ) : ( + data?.pages.map((page) => + page.docs.map((tag) => ( +
handleClickTag(tag.name)} + > +

{tag.name}

+ handleClickTag(tag.name)} + /> +
+ )) + ) + )} + + {hasNextPage && ( + + )} +
+ ); +}; diff --git a/src/modules/tags/server/procedures.ts b/src/modules/tags/server/procedures.ts new file mode 100644 index 0000000..36cbf2f --- /dev/null +++ b/src/modules/tags/server/procedures.ts @@ -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; + }) +}) \ No newline at end of file diff --git a/src/payload-types.ts b/src/payload-types.ts index 0784f2c..a994e82 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | MediaSelect; categories: CategoriesSelect | CategoriesSelect; products: ProductsSelect | ProductsSelect; + tags: TagsSelect | TagsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { 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 { + name?: T; + product?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index 26a05bc..cbe2bd0 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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: { diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 0432115..fc00554 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -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, });