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",