Merge pull request #10 from Nezumi-2711/feat/filtering-product

Feature/ Implement filtering for the product
This commit is contained in:
2025-07-21 21:43:46 +07:00
committed by GitHub
11 changed files with 233 additions and 30 deletions
+5
View File
@@ -50,6 +50,7 @@
"lucide-react": "^0.503.0",
"next": "15.2.4",
"next-themes": "^0.4.6",
"nuqs": "2.4.1",
"payload": "^3.36.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
@@ -1473,6 +1474,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"monaco-editor": ["monaco-editor@0.52.2", "", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="],
"mongodb": ["mongodb@6.12.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.1", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.2.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA=="],
@@ -1505,6 +1508,8 @@
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"nuqs": ["nuqs@2.4.1", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "@remix-run/react": ">=2", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "next", "react-router", "react-router-dom"] }, "sha512-u6sngTspqDe3jWHtcmqHQg3dl35niizCZAsm5gy7PBlgG2rwl71Dp2QUv5hwBaWKI9qz0wqILZY86TsRxq66SQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+1
View File
@@ -59,6 +59,7 @@
"lucide-react": "^0.503.0",
"next": "15.2.4",
"next-themes": "^0.4.6",
"nuqs": "2.4.1",
"payload": "^3.36.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
+10
View File
@@ -6,6 +6,7 @@ import {
ProductList,
ProductListSkeleton,
} from '@/modules/products/ui/components/product-list';
import { ProductFilters } from '@/modules/products/ui/components/product-filters';
interface Props {
params: Promise<{
@@ -24,9 +25,18 @@ const Page = async ({ params }: Props) => {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4">
<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 />
</div>
<div className="lg:col-span-4 xl:col-span-6">
<Suspense fallback={<ProductListSkeleton />}>
<ProductList category={category} />
</Suspense>
</div>
</div>
</div>
</HydrationBoundary>
);
};
+6 -3
View File
@@ -1,11 +1,12 @@
import { ReactNode } from 'react';
import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google';
import { ReactNode } from "react";
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import { TRPCReactProvider } from '@/trpc/client';
// Components
import { Toaster } from "@/components/ui/sonner";
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
@@ -24,12 +25,14 @@ export default function RootLayout({
children: ReactNode;
}>) {
return (
<html lang='en'>
<html lang="en">
<body className={`${dmSans.className} antialiased`}>
<NuqsAdapter>
<TRPCReactProvider>
{children}
<Toaster />
</TRPCReactProvider>
</NuqsAdapter>
</body>
</html>
);
@@ -20,7 +20,6 @@ export const categoriesRouter = createTRPCRouter({
...category,
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
...(doc as Category),
subcategories: undefined,
})),
}));
@@ -0,0 +1,12 @@
import { parseAsString, useQueryStates } from 'nuqs';
export const useProductFilters = () => {
return useQueryStates({
minPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
maxPrice: parseAsString.withOptions({
clearOnDefault: true,
}),
})
}
+23 -4
View File
@@ -1,13 +1,33 @@
import type { Where } from "payload";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { z } from "zod";
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
import { Category } from "@/payload-types";
export const productsRouter = createTRPCRouter({
getMany: baseProcedure.input(z.object({
category: z.string().nullable().optional(),
minPrice: z.string().nullable().optional(),
maxPrice: z.string().nullable().optional(),
})).query(async ({ ctx, input }) => {
const where: Where = {}
const where: Where = {
price: {},
}
if (input.minPrice && input.maxPrice) {
where.price = {
greater_than_equal: input.minPrice,
less_than_equal: input.maxPrice,
}
} else if (input.minPrice) {
where.price = {
greater_than_equal: input.minPrice,
}
} else if (input.maxPrice) {
where.price = {
less_than_equal: input.maxPrice,
}
}
if (input.category) {
const categoriesData = await ctx.db.find({
@@ -38,13 +58,12 @@ export const productsRouter = createTRPCRouter({
subCategoriesSlugs.push(
...parentCategory.subcategories.map((subcategory) => subcategory.slug)
);
}
if (parentCategory) {
where['category.slug'] = {
in: [parentCategory.slug, ...subCategoriesSlugs],
}
}
}
const data = await ctx.db.find({
@@ -0,0 +1,80 @@
import { ChangeEvent } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
minPrice?: string | null;
maxPrice?: string | null;
onMinPriceChange?: (value: string) => void;
onMaxPriceChange?: (value: string) => void;
}
export const formatAsCurrency = (value: string) => {
const numericValue = value.replace(/[^0-9.]/g, '');
const parts = numericValue.split('.');
const formattedValue =
parts[0] + (parts.length > 1 ? '.' + parts[1]?.slice(0, 2) : '');
if (!formattedValue) return '';
const numberValue = parseFloat(formattedValue);
if (isNaN(numberValue)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(numberValue);
};
export const PriceFilter = ({
minPrice,
maxPrice,
onMinPriceChange,
onMaxPriceChange,
}: Props) => {
const handleMinPriceChange = (event: ChangeEvent<HTMLInputElement>) => {
const numericValue = event.target.value.replace(/[^0-9.]/g, '');
onMinPriceChange?.(numericValue);
}
const handleMaxPriceChange = (event: ChangeEvent<HTMLInputElement>) => {
const numericValue = event.target.value.replace(/[^0-9.]/g, '');
onMaxPriceChange?.(numericValue);
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Label className="font-medium text-base">
Minimum price
</Label>
<Input
type="text"
placeholder="$0"
value={minPrice ? formatAsCurrency(minPrice) : ''}
onChange={handleMinPriceChange}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="font-medium text-base">
Maximum price
</Label>
<Input
type="text"
placeholder="∞"
value={maxPrice ? formatAsCurrency(maxPrice) : ''}
onChange={handleMaxPriceChange}
/>
</div>
</div>
);
};
@@ -0,0 +1,63 @@
'use client';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { cn } from '@/lib/utils';
import { PriceFilter } from './price-filter';
import { useProductFilters } from '../../hooks/use-product-filters';
interface ProductFiltersProps {
title: string;
className?: string;
children: ReactNode;
}
const ProductFilter = ({ title, className, children }: ProductFiltersProps) => {
const [isOpen, setIsOpen] = useState(false);
const Icon = isOpen ? ChevronDownIcon : ChevronRightIcon;
return (
<div className={cn('p-4 border-b flex flex-col gap-2', className)}>
<div
onClick={() => setIsOpen((current) => !current)}
className="flex items-center justify-between cursor-pointer"
>
<p className="font-medium">{title}</p>
<Icon className="size-5" />
</div>
{isOpen && children}
</div>
);
};
export const ProductFilters = () => {
const [filters, setFilters] = useProductFilters();
const onChange = (key: keyof typeof filters, value: unknown) => {
setFilters({ ...filters, [key]: value });
};
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>
</div>
<ProductFilter title="Price" className="border-b-0">
<PriceFilter
minPrice={filters.minPrice}
maxPrice={filters.maxPrice}
onMinPriceChange={(value) => onChange('minPrice', value)}
onMaxPriceChange={(value) => onChange('maxPrice', value)}
/>
</ProductFilter>
</div>
);
};
@@ -8,14 +8,24 @@ interface Props {
category: string;
}
export const ProductList = ({ category }: Props) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.products.getMany.queryOptions({
category
}));
const { data } = useSuspenseQuery(
trpc.products.getMany.queryOptions({
category,
})
);
return <div>{JSON.stringify(data)}</div>;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{data?.docs.map((product) => (
<div key={product.id} className="border rounded-md bg-white p-4">
<h2 className="text-xl font-medium">{product.name}</h2>
<p>${product.price}</p>
</div>
))}
</div>
);
};
export const ProductListSkeleton = () => {
+1
View File
@@ -9,6 +9,7 @@
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",