mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 13:38:34 +00:00
feat: implement filtering for the product
This commit is contained in:
@@ -50,6 +50,7 @@
|
|||||||
"lucide-react": "^0.503.0",
|
"lucide-react": "^0.503.0",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nuqs": "2.4.1",
|
||||||
"payload": "^3.36.0",
|
"payload": "^3.36.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
@@ -1473,6 +1474,8 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"lucide-react": "^0.503.0",
|
"lucide-react": "^0.503.0",
|
||||||
"next": "15.2.4",
|
"next": "15.2.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nuqs": "2.4.1",
|
||||||
"payload": "^3.36.0",
|
"payload": "^3.36.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ProductList,
|
ProductList,
|
||||||
ProductListSkeleton,
|
ProductListSkeleton,
|
||||||
} from '@/modules/products/ui/components/product-list';
|
} from '@/modules/products/ui/components/product-list';
|
||||||
|
import { ProductFilters } from '@/modules/products/ui/components/product-filters';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -24,9 +25,18 @@ const Page = async ({ params }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Suspense fallback={<ProductListSkeleton />}>
|
<div className="px-4 lg:px-12 py-8 flex flex-col gap-4">
|
||||||
<ProductList category={category} />
|
<div className="grid grid-cols-1 lg:grid-cols-6 xl:grid-cols-8 gap-y-6 gap-x-12">
|
||||||
</Suspense>
|
<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>
|
</HydrationBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+20
-17
@@ -1,36 +1,39 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { DM_Sans } from 'next/font/google';
|
import { DM_Sans } from 'next/font/google';
|
||||||
import { ReactNode } from "react";
|
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||||
|
|
||||||
import { TRPCReactProvider } from '@/trpc/client';
|
import { TRPCReactProvider } from '@/trpc/client';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const dmSans = DM_Sans({
|
const dmSans = DM_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: 'Create Next App',
|
||||||
description: 'Generated by create next app',
|
description: 'Generated by create next app',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang="en">
|
||||||
<body className={`${dmSans.className} antialiased`}>
|
<body className={`${dmSans.className} antialiased`}>
|
||||||
<TRPCReactProvider>
|
<NuqsAdapter>
|
||||||
{children}
|
<TRPCReactProvider>
|
||||||
<Toaster />
|
{children}
|
||||||
</TRPCReactProvider>
|
<Toaster />
|
||||||
</body>
|
</TRPCReactProvider>
|
||||||
</html>
|
</NuqsAdapter>
|
||||||
);
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const categoriesRouter = createTRPCRouter({
|
|||||||
...category,
|
...category,
|
||||||
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
|
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
|
||||||
...(doc as Category),
|
...(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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import type { Where } from "payload";
|
import type { Where } from "payload";
|
||||||
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { baseProcedure, createTRPCRouter } from "@/trpc/init";
|
||||||
import { Category } from "@/payload-types";
|
import { Category } from "@/payload-types";
|
||||||
|
|
||||||
export const productsRouter = createTRPCRouter({
|
export const productsRouter = createTRPCRouter({
|
||||||
getMany: baseProcedure.input(z.object({
|
getMany: baseProcedure.input(z.object({
|
||||||
category: z.string().nullable().optional(),
|
category: z.string().nullable().optional(),
|
||||||
|
minPrice: z.string().nullable().optional(),
|
||||||
|
maxPrice: z.string().nullable().optional(),
|
||||||
})).query(async ({ ctx, input }) => {
|
})).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) {
|
if (input.category) {
|
||||||
const categoriesData = await ctx.db.find({
|
const categoriesData = await ctx.db.find({
|
||||||
@@ -38,13 +58,12 @@ export const productsRouter = createTRPCRouter({
|
|||||||
subCategoriesSlugs.push(
|
subCategoriesSlugs.push(
|
||||||
...parentCategory.subcategories.map((subcategory) => subcategory.slug)
|
...parentCategory.subcategories.map((subcategory) => subcategory.slug)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (parentCategory) {
|
|
||||||
where['category.slug'] = {
|
where['category.slug'] = {
|
||||||
in: [parentCategory.slug, ...subCategoriesSlugs],
|
in: [parentCategory.slug, ...subCategoriesSlugs],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await ctx.db.find({
|
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;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const ProductList = ({ category }: Props) => {
|
export const ProductList = ({ category }: Props) => {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { data } = useSuspenseQuery(trpc.products.getMany.queryOptions({
|
const { data } = useSuspenseQuery(
|
||||||
category
|
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 = () => {
|
export const ProductListSkeleton = () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
|
|||||||
Reference in New Issue
Block a user