From 7732e680d4cc7e2072d10ac90a054e871ece23c6 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Mon, 21 Jul 2025 21:32:40 +0700 Subject: [PATCH] feat: implement filtering for the product --- bun.lock | 5 ++ package.json | 1 + src/app/(app)/(home)/[category]/page.tsx | 16 +++- src/app/(app)/layout.tsx | 37 +++++---- src/modules/categories/server/procedures.ts | 1 - .../products/hooks/use-product-filters.ts | 12 +++ src/modules/products/server/procedures.ts | 27 ++++++- .../products/ui/components/price-filter.tsx | 80 +++++++++++++++++++ .../ui/components/product-filters.tsx | 63 +++++++++++++++ .../products/ui/components/product-list.tsx | 20 +++-- tsconfig.json | 1 + 11 files changed, 233 insertions(+), 30 deletions(-) create mode 100644 src/modules/products/hooks/use-product-filters.ts create mode 100644 src/modules/products/ui/components/price-filter.tsx create mode 100644 src/modules/products/ui/components/product-filters.tsx diff --git a/bun.lock b/bun.lock index 31aebc6..d18abe4 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index d312693..e52a360 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(app)/(home)/[category]/page.tsx b/src/app/(app)/(home)/[category]/page.tsx index 2c62289..82e76b0 100644 --- a/src/app/(app)/(home)/[category]/page.tsx +++ b/src/app/(app)/(home)/[category]/page.tsx @@ -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 ( - }> - - +
+
+
+ +
+
+ }> + + +
+
+
); }; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 53406cd..0cebd79 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,36 +1,39 @@ +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'; const dmSans = DM_Sans({ - subsets: ['latin'], + subsets: ['latin'], }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Create Next App', + description: 'Generated by create next app', }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: ReactNode; + children: ReactNode; }>) { - return ( - - - - {children} - - - - - ); + return ( + + + + + {children} + + + + + + ); } diff --git a/src/modules/categories/server/procedures.ts b/src/modules/categories/server/procedures.ts index 9cf3944..394a09a 100644 --- a/src/modules/categories/server/procedures.ts +++ b/src/modules/categories/server/procedures.ts @@ -20,7 +20,6 @@ export const categoriesRouter = createTRPCRouter({ ...category, subcategories: (category.subcategories?.docs ?? []).map((doc) => ({ ...(doc as Category), - subcategories: undefined, })), })); diff --git a/src/modules/products/hooks/use-product-filters.ts b/src/modules/products/hooks/use-product-filters.ts new file mode 100644 index 0000000..27de10b --- /dev/null +++ b/src/modules/products/hooks/use-product-filters.ts @@ -0,0 +1,12 @@ +import { parseAsString, useQueryStates } from 'nuqs'; + +export const useProductFilters = () => { + return useQueryStates({ + minPrice: parseAsString.withOptions({ + clearOnDefault: true, + }), + maxPrice: parseAsString.withOptions({ + clearOnDefault: true, + }), + }) +} \ No newline at end of file diff --git a/src/modules/products/server/procedures.ts b/src/modules/products/server/procedures.ts index 9408c20..edc7295 100644 --- a/src/modules/products/server/procedures.ts +++ b/src/modules/products/server/procedures.ts @@ -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({ diff --git a/src/modules/products/ui/components/price-filter.tsx b/src/modules/products/ui/components/price-filter.tsx new file mode 100644 index 0000000..4fd8141 --- /dev/null +++ b/src/modules/products/ui/components/price-filter.tsx @@ -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) => { + const numericValue = event.target.value.replace(/[^0-9.]/g, ''); + + onMinPriceChange?.(numericValue); + } + + const handleMaxPriceChange = (event: ChangeEvent) => { + const numericValue = event.target.value.replace(/[^0-9.]/g, ''); + + onMaxPriceChange?.(numericValue); + } + + return ( +
+
+ + + +
+ +
+ + + +
+
+ ); +}; diff --git a/src/modules/products/ui/components/product-filters.tsx b/src/modules/products/ui/components/product-filters.tsx new file mode 100644 index 0000000..dcf5f07 --- /dev/null +++ b/src/modules/products/ui/components/product-filters.tsx @@ -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 ( +
+
setIsOpen((current) => !current)} + className="flex items-center justify-between cursor-pointer" + > +

{title}

+ +
+ {isOpen && children} +
+ ); +}; + +export const ProductFilters = () => { + const [filters, setFilters] = useProductFilters(); + + const onChange = (key: keyof typeof filters, value: unknown) => { + setFilters({ ...filters, [key]: value }); + }; + + return ( +
+
+

Filters

+ + +
+ + + onChange('minPrice', value)} + onMaxPriceChange={(value) => onChange('maxPrice', value)} + /> + +
+ ); +}; diff --git a/src/modules/products/ui/components/product-list.tsx b/src/modules/products/ui/components/product-list.tsx index 5db4a9a..6ce559e 100644 --- a/src/modules/products/ui/components/product-list.tsx +++ b/src/modules/products/ui/components/product-list.tsx @@ -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
{JSON.stringify(data)}
; + return ( +
+ {data?.docs.map((product) => ( +
+

{product.name}

+

${product.price}

+
+ ))} +
+ ); }; export const ProductListSkeleton = () => { diff --git a/tsconfig.json b/tsconfig.json index a389636..05be1ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noUncheckedIndexedAccess": true, "noEmit": true, "esModuleInterop": true, "module": "esnext",