mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 13:38:34 +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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"generate:types": "payload generate:types"
|
"generate:types": "payload generate:types",
|
||||||
|
"db:fresh": "payload migrate:fresh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
|
||||||
import Navbar from './navbar';
|
import Navbar from './navbar';
|
||||||
import Footer from './footer';
|
import Footer from './footer';
|
||||||
|
import SearchFilters from './search-filters';
|
||||||
|
import { Category } from '@/payload-types';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
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 (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
|
<SearchFilters data={formattedData} />
|
||||||
|
|
||||||
<div className="flex-1 bg-[#F4F4F0]">{children}</div>
|
<div className="flex-1 bg-[#F4F4F0]">{children}</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
import configPromise from '@payload-config';
|
export default function Home() {
|
||||||
import { getPayload } from 'payload';
|
return <div>Home page</div>;
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
name: 'name',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: true,
|
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-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
};
|
};
|
||||||
collectionsJoins: {};
|
collectionsJoins: {
|
||||||
|
categories: {
|
||||||
|
subcategories: 'categories';
|
||||||
|
};
|
||||||
|
};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
@@ -158,6 +162,14 @@ export interface Media {
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color?: string | null;
|
||||||
|
parent?: (string | null) | Category;
|
||||||
|
subcategories?: {
|
||||||
|
docs?: (string | Category)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -261,6 +273,10 @@ export interface MediaSelect<T extends boolean = true> {
|
|||||||
*/
|
*/
|
||||||
export interface CategoriesSelect<T extends boolean = true> {
|
export interface CategoriesSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
|
slug?: T;
|
||||||
|
color?: T;
|
||||||
|
parent?: T;
|
||||||
|
subcategories?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user