mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 05:31:56 +00:00
Merge pull request #3 from Nezumi-2711/feat/search-filters
Feature/ Implement the search filter
This commit is contained in:
+2
-1
@@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"generate:types": "payload generate:types"
|
||||
"generate:types": "payload generate:types",
|
||||
"db:fresh": "payload migrate:fresh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import { ReactNode } from 'react';
|
||||
import configPromise from '@payload-config';
|
||||
import { getPayload } from 'payload';
|
||||
|
||||
import Navbar from './navbar';
|
||||
import Footer from './footer';
|
||||
import SearchFilters from './search-filters';
|
||||
import { Category } from '@/payload-types';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const Layout = async ({ children }: LayoutProps) => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
});
|
||||
|
||||
const data = await payload.find({
|
||||
collection: 'categories',
|
||||
depth: 1,
|
||||
where: {
|
||||
parent: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const formattedData = data.docs.map((category) => ({
|
||||
...category,
|
||||
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
|
||||
...(doc as Category),
|
||||
subcategories: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar />
|
||||
|
||||
<SearchFilters data={formattedData} />
|
||||
|
||||
<div className="flex-1 bg-[#F4F4F0]">{children}</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import configPromise from '@payload-config';
|
||||
import { getPayload } from 'payload';
|
||||
|
||||
export default async function Home() {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
});
|
||||
|
||||
const data = await payload.find({ collection: 'categories' });
|
||||
|
||||
return <div>{JSON.stringify(data, null, 2)}</div>;
|
||||
export default function Home() {
|
||||
return <div>Home page</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Category } from '@/payload-types';
|
||||
import CategoryDropdown from './category-dropdown';
|
||||
|
||||
interface CategoriesProps {
|
||||
data: any;
|
||||
}
|
||||
|
||||
const Categories = ({ data }: CategoriesProps) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex flex-nowrap items-center">
|
||||
{data.map((category: Category) => (
|
||||
<div key={category.id}>
|
||||
<CategoryDropdown
|
||||
category={category}
|
||||
isActive={false}
|
||||
isNavigationHovered={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Category } from '@/payload-types';
|
||||
import { useRef, useState } from 'react';
|
||||
import useDropdownPosition from './use-dropdown-position';
|
||||
import SubCategoryMenu from './subcategory-menu';
|
||||
|
||||
interface CategoryDropdownProps {
|
||||
category: Category;
|
||||
isActive?: boolean;
|
||||
isNavigationHovered?: boolean;
|
||||
}
|
||||
|
||||
const CategoryDropdown = ({
|
||||
category,
|
||||
isActive,
|
||||
isNavigationHovered,
|
||||
}: CategoryDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
|
||||
const dropdownPosition = getDropdownPosition();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (category.subcategories) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="elevated"
|
||||
className={cn(
|
||||
'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
|
||||
isActive && !isNavigationHovered && 'bg-white border-primary'
|
||||
)}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
|
||||
{category.subcategories && category.subcategories.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'opacity-0 absolute -bottom-3 w-0 h-0 border-l-[10px] border-r-[10px] border-b-[10px] border-l-transparent border-r-transparent border-b-black left-1/2 -translate-x-1/2',
|
||||
isOpen && 'opacity-100'
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubCategoryMenu
|
||||
category={category}
|
||||
isOpen={isOpen}
|
||||
position={dropdownPosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryDropdown;
|
||||
@@ -0,0 +1,18 @@
|
||||
import Categories from './categories';
|
||||
import SearchInput from './search-input';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
data: any;
|
||||
}
|
||||
|
||||
const SearchFilters = ({ data }: SearchFiltersProps) => {
|
||||
return (
|
||||
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full">
|
||||
<SearchInput />
|
||||
|
||||
<Categories data={data} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchFilters;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
interface SearchInputProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SearchInput = ({ disabled }: SearchInputProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className='relative w-full'>
|
||||
<SearchIcon className='absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-500' />
|
||||
|
||||
<Input className='pl-8' placeholder='Search products' disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Category } from '@/payload-types';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SubCategoryMenuProps {
|
||||
category: Category;
|
||||
isOpen: boolean;
|
||||
position: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
}
|
||||
|
||||
const SubCategoryMenu = ({
|
||||
category,
|
||||
isOpen,
|
||||
position,
|
||||
}: SubCategoryMenuProps) => {
|
||||
if (
|
||||
!isOpen ||
|
||||
!category.subcategories ||
|
||||
category.subcategories.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backgroundColor = category.color || '#F5F5F5';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-100"
|
||||
style={{
|
||||
top: position?.top,
|
||||
left: position?.left,
|
||||
}}
|
||||
>
|
||||
{/* Invisible bridge to maintain hover */}
|
||||
<div className="h-3 w-60" />
|
||||
<div
|
||||
className="w-60 text-black rounded-md overflow-hidden border shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[2px] -translate-y-[2px]"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div>
|
||||
{category.subcategories.map((subcategory: Category) => (
|
||||
<Link
|
||||
key={subcategory.id}
|
||||
href="/"
|
||||
className="w-full text-left p-4 hover:bg-black hover:text-white flex justify-between items-center underline font-medium"
|
||||
>
|
||||
{subcategory.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubCategoryMenu;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
|
||||
const getDropdownPosition = () => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px)
|
||||
|
||||
// Calculate the initial position
|
||||
let left = rect.left + window.scrollX;
|
||||
const top = rect.bottom + window.scrollY;
|
||||
|
||||
// Check if dropdown would go off the right edge of the viewport
|
||||
if (left + dropdownWidth > window.innerWidth) {
|
||||
left = rect.right + window.scrollX - dropdownWidth;
|
||||
|
||||
// If still off-screen, align to the left edge of the viewport with some padding
|
||||
if (left < 0) {
|
||||
left = window.innerWidth - dropdownWidth - 16; // 16px padding
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 16
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
}
|
||||
|
||||
return { getDropdownPosition }
|
||||
}
|
||||
|
||||
export default useDropdownPosition;
|
||||
@@ -7,6 +7,30 @@ export const Categories: CollectionConfig = {
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: 'color',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'parent',
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
hasMany: false,
|
||||
},
|
||||
{
|
||||
name: 'subcategories',
|
||||
type: 'join',
|
||||
collection: 'categories',
|
||||
on: 'parent',
|
||||
hasMany: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
-1
@@ -74,7 +74,11 @@ export interface Config {
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsJoins: {
|
||||
categories: {
|
||||
subcategories: 'categories';
|
||||
};
|
||||
};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
@@ -158,6 +162,14 @@ export interface Media {
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
parent?: (string | null) | Category;
|
||||
subcategories?: {
|
||||
docs?: (string | Category)[];
|
||||
hasNextPage?: boolean;
|
||||
totalDocs?: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -261,6 +273,10 @@ export interface MediaSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface CategoriesSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
slug?: T;
|
||||
color?: T;
|
||||
parent?: T;
|
||||
subcategories?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user