mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-23 04:10:02 +00:00
feat: implement filtering for the product
This commit is contained in:
@@ -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)}>
|
||||
<Suspense fallback={<ProductListSkeleton />}>
|
||||
<ProductList category={category} />
|
||||
</Suspense>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-17
@@ -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 (
|
||||
<html lang='en'>
|
||||
<body className={`${dmSans.className} antialiased`}>
|
||||
<TRPCReactProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user