mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 05:31:56 +00:00
feat: implement filter feature
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -26,6 +26,12 @@ export const Products: CollectionConfig = {
|
||||
relationTo: 'categories',
|
||||
hasMany: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'relationship',
|
||||
relationTo: 'tags',
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: 'images',
|
||||
type: 'upload',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_LIMIT = 8;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
})
|
||||
})
|
||||
@@ -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".
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user