Merge pull request #3 from Nezumi-2711/feat/search-filters

Feature/ Implement the search filter
This commit is contained in:
2025-05-01 10:15:52 +07:00
committed by GitHub
11 changed files with 303 additions and 14 deletions
+2 -1
View File
@@ -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",
+29 -1
View File
@@ -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 />
+2 -11
View File
@@ -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;
+24
View File
@@ -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
View File
@@ -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;
} }