mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 13:38:34 +00:00
feat: complete categories feature
This commit is contained in:
+78
-77
@@ -1,79 +1,80 @@
|
|||||||
{
|
{
|
||||||
"name": "multitenant-ecommerce",
|
"name": "multitenant-ecommerce",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"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"
|
"db:fresh": "payload migrate:fresh",
|
||||||
},
|
"db:seed": "bun run src/seed.ts"
|
||||||
"dependencies": {
|
},
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"dependencies": {
|
||||||
"@payloadcms/db-mongodb": "^3.36.0",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@payloadcms/next": "^3.36.0",
|
"@payloadcms/db-mongodb": "^3.36.0",
|
||||||
"@payloadcms/payload-cloud": "^3.36.0",
|
"@payloadcms/next": "^3.36.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.36.0",
|
"@payloadcms/payload-cloud": "^3.36.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@payloadcms/richtext-lexical": "^3.36.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.11",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.11",
|
||||||
"@radix-ui/react-avatar": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.4",
|
||||||
"@radix-ui/react-checkbox": "^1.2.3",
|
"@radix-ui/react-avatar": "^1.1.7",
|
||||||
"@radix-ui/react-collapsible": "^1.1.8",
|
"@radix-ui/react-checkbox": "^1.2.3",
|
||||||
"@radix-ui/react-context-menu": "^2.2.12",
|
"@radix-ui/react-collapsible": "^1.1.8",
|
||||||
"@radix-ui/react-dialog": "^1.1.11",
|
"@radix-ui/react-context-menu": "^2.2.12",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dialog": "^1.1.11",
|
||||||
"@radix-ui/react-hover-card": "^1.1.11",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-hover-card": "^1.1.11",
|
||||||
"@radix-ui/react-menubar": "^1.1.12",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.10",
|
"@radix-ui/react-menubar": "^1.1.12",
|
||||||
"@radix-ui/react-popover": "^1.1.11",
|
"@radix-ui/react-navigation-menu": "^1.2.10",
|
||||||
"@radix-ui/react-progress": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.11",
|
||||||
"@radix-ui/react-radio-group": "^1.3.4",
|
"@radix-ui/react-progress": "^1.1.4",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.6",
|
"@radix-ui/react-radio-group": "^1.3.4",
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
"@radix-ui/react-scroll-area": "^1.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.4",
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
"@radix-ui/react-separator": "^1.1.4",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-switch": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.9",
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
"@radix-ui/react-toggle": "^1.1.6",
|
"@radix-ui/react-tabs": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.7",
|
"@radix-ui/react-toggle": "^1.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.2.4",
|
"@radix-ui/react-toggle-group": "^1.1.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cmdk": "^1.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"date-fns": "^4.1.0",
|
||||||
"graphql": "^16.8.1",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"graphql": "^16.8.1",
|
||||||
"lucide-react": "^0.503.0",
|
"input-otp": "^1.4.2",
|
||||||
"next": "15.2.4",
|
"lucide-react": "^0.503.0",
|
||||||
"next-themes": "^0.4.6",
|
"next": "15.2.4",
|
||||||
"payload": "^3.36.0",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"payload": "^3.36.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-day-picker": "8.10.1",
|
||||||
"react-hook-form": "^7.56.1",
|
"react-dom": "^19.0.0",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-hook-form": "^7.56.1",
|
||||||
"recharts": "^2.15.3",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"sonner": "^2.0.3",
|
"recharts": "^2.15.3",
|
||||||
"tailwind-merge": "^3.2.0",
|
"sonner": "^2.0.3",
|
||||||
"tw-animate-css": "1.2.5",
|
"tailwind-merge": "^3.2.0",
|
||||||
"vaul": "^1.1.2",
|
"tw-animate-css": "1.2.5",
|
||||||
"zod": "^3.24.3"
|
"vaul": "^1.1.2",
|
||||||
},
|
"zod": "^3.24.3"
|
||||||
"devDependencies": {
|
},
|
||||||
"typescript": "^5",
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"typescript": "^5",
|
||||||
"@types/react": "^19",
|
"@types/node": "^20",
|
||||||
"@types/react-dom": "^19",
|
"@types/react": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@types/react-dom": "^19",
|
||||||
"tailwindcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"eslint": "^9",
|
"tailwindcss": "^4",
|
||||||
"eslint-config-next": "15.2.4",
|
"eslint": "^9",
|
||||||
"@eslint/eslintrc": "^3"
|
"eslint-config-next": "15.2.4",
|
||||||
}
|
"@eslint/eslintrc": "^3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Navbar from './navbar';
|
|||||||
import Footer from './footer';
|
import Footer from './footer';
|
||||||
import SearchFilters from './search-filters';
|
import SearchFilters from './search-filters';
|
||||||
import { Category } from '@/payload-types';
|
import { Category } from '@/payload-types';
|
||||||
|
import { CustomCategory } from './type';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -24,9 +25,10 @@ const Layout = async ({ children }: LayoutProps) => {
|
|||||||
exists: false,
|
exists: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sort: 'name',
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedData = data.docs.map((category) => ({
|
const formattedData: CustomCategory[] = data.docs.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
|
subcategories: (category.subcategories?.docs ?? []).map((doc) => ({
|
||||||
...(doc as Category),
|
...(doc as Category),
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { CustomCategory } from '../type';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface CategoriesSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
data: CustomCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoriesSidebar = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
}: CategoriesSidebarProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [parentCategories, setParentCategories] = useState<
|
||||||
|
CustomCategory[] | null
|
||||||
|
>(null);
|
||||||
|
const [selectedCategory, setSelectedCategory] =
|
||||||
|
useState<CustomCategory | null>(null);
|
||||||
|
|
||||||
|
// If we have parent categories, show those, otherwise show root categories
|
||||||
|
const currentCategories = parentCategories ?? data ?? [];
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setSelectedCategory(null);
|
||||||
|
setParentCategories(null);
|
||||||
|
onOpenChange(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (category: CustomCategory) => {
|
||||||
|
if (category.subcategories && category.subcategories.length > 0) {
|
||||||
|
setParentCategories(category.subcategories as CustomCategory[]);
|
||||||
|
setSelectedCategory(category);
|
||||||
|
} else {
|
||||||
|
// This is a leaf category (no subcategories)
|
||||||
|
if (parentCategories && selectedCategory) {
|
||||||
|
router.push(`/${selectedCategory.slug}/${category.slug}`);
|
||||||
|
} else {
|
||||||
|
if (category.slug === 'all') {
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
router.push(`/${category.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
if (parentCategories) {
|
||||||
|
setParentCategories(null);
|
||||||
|
setSelectedCategory(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundColor = selectedCategory?.color || 'white';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="p-0 transition-none"
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
>
|
||||||
|
<SheetHeader className="p-4 border-b">
|
||||||
|
<SheetTitle>Categories</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex flex-col overflow-y-auto h-full pb-2">
|
||||||
|
{parentCategories && (
|
||||||
|
<button
|
||||||
|
onClick={handleBackClick}
|
||||||
|
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentCategories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.slug}
|
||||||
|
onClick={() => handleCategoryClick(category)}
|
||||||
|
className="w-full text-left p-4 hover:bg-black hover:text-white flex justify-between items-center text-base font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
{category.subcategories && category.subcategories.length > 0 && (
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesSidebar;
|
||||||
@@ -1,24 +1,126 @@
|
|||||||
import { Category } from '@/payload-types';
|
'use client';
|
||||||
|
|
||||||
|
import { ListFilterIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import CategoryDropdown from './category-dropdown';
|
import CategoryDropdown from './category-dropdown';
|
||||||
|
|
||||||
|
import { CustomCategory } from '../type';
|
||||||
|
import CategoriesSidebar from './categories-sidebar';
|
||||||
|
|
||||||
interface CategoriesProps {
|
interface CategoriesProps {
|
||||||
data: any;
|
data: CustomCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Categories = ({ data }: CategoriesProps) => {
|
const Categories = ({ data }: CategoriesProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const measureRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewAllRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [visibleCount, setVisibleCount] = useState(data.length);
|
||||||
|
const [isAnyHovered, setIsAnyHovered] = useState(false);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const activeCategory = 'all';
|
||||||
|
|
||||||
|
const activeCategoryIndex = data.findIndex(
|
||||||
|
(cat) => cat.slug === activeCategory
|
||||||
|
);
|
||||||
|
const isActiveCategoryHidden =
|
||||||
|
activeCategoryIndex >= visibleCount && activeCategoryIndex !== -1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateVisible = () => {
|
||||||
|
if (!containerRef.current || !measureRef.current || !viewAllRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const viewAllWidth = viewAllRef.current.offsetWidth;
|
||||||
|
const availableWidth = containerWidth - viewAllWidth;
|
||||||
|
|
||||||
|
const items = Array.from(measureRef.current.children);
|
||||||
|
let totalWidth = 0;
|
||||||
|
let visible = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const width = item.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
if (totalWidth + width > availableWidth) break;
|
||||||
|
|
||||||
|
totalWidth += width;
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleCount(visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(calculateVisible);
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.current!);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [data.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<div className="flex flex-nowrap items-center">
|
{/* Categories sidebar */}
|
||||||
{data.map((category: Category) => (
|
<CategoriesSidebar
|
||||||
|
open={isSidebarOpen}
|
||||||
|
onOpenChange={setIsSidebarOpen}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hidden div to measure all items */}
|
||||||
|
<div
|
||||||
|
ref={measureRef}
|
||||||
|
className="absolute opacity-0 pointer-events-none flex"
|
||||||
|
style={{ position: 'fixed', top: -9999, left: -9999 }}
|
||||||
|
>
|
||||||
|
{data.map((category) => (
|
||||||
<div key={category.id}>
|
<div key={category.id}>
|
||||||
<CategoryDropdown
|
<CategoryDropdown
|
||||||
category={category}
|
category={category}
|
||||||
isActive={false}
|
isActive={activeCategory === category.slug}
|
||||||
isNavigationHovered={false}
|
isNavigationHovered={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visible items */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex flex-nowrap items-center"
|
||||||
|
onMouseEnter={() => setIsAnyHovered(true)}
|
||||||
|
onMouseLeave={() => setIsAnyHovered(false)}
|
||||||
|
>
|
||||||
|
{data.slice(0, visibleCount).map((category) => (
|
||||||
|
<div key={category.id}>
|
||||||
|
<CategoryDropdown
|
||||||
|
category={category}
|
||||||
|
isActive={activeCategory === category.slug}
|
||||||
|
isNavigationHovered={isAnyHovered}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={viewAllRef} className="shrink-0">
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
|
||||||
|
isActiveCategoryHidden &&
|
||||||
|
!isAnyHovered &&
|
||||||
|
'bg-white border-primary'
|
||||||
|
)}
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
<ListFilterIcon className="ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Category } from '@/payload-types';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import useDropdownPosition from './use-dropdown-position';
|
import useDropdownPosition from './use-dropdown-position';
|
||||||
import SubCategoryMenu from './subcategory-menu';
|
import SubCategoryMenu from './subcategory-menu';
|
||||||
|
|
||||||
|
import { CustomCategory } from '../type';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface CategoryDropdownProps {
|
interface CategoryDropdownProps {
|
||||||
category: Category;
|
category: CustomCategory;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
isNavigationHovered?: boolean;
|
isNavigationHovered?: boolean;
|
||||||
}
|
}
|
||||||
@@ -21,7 +24,14 @@ const CategoryDropdown = ({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
|
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
|
||||||
const dropdownPosition = getDropdownPosition();
|
const dropdownPosition = getDropdownPosition();
|
||||||
|
|
||||||
|
// TODO: Will improve in mobile screen
|
||||||
|
// const toggleDropdown = () => {
|
||||||
|
// if (category.subcategories?.docs?.length) {
|
||||||
|
// setIsOpen(!isOpen);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (category.subcategories) {
|
if (category.subcategories) {
|
||||||
@@ -37,16 +47,21 @@ const CategoryDropdown = ({
|
|||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
// onClick={toggleDropdown}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
|
'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
|
||||||
isActive && !isNavigationHovered && 'bg-white border-primary'
|
isActive && !isNavigationHovered && 'bg-white border-primary',
|
||||||
|
isOpen &&
|
||||||
|
'bg-white border-primary shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[4px] -translate-y-[4px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{category.name}
|
<Link href={`/${category.slug === 'all' ? '' : category.slug}`}>
|
||||||
|
{category.name}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{category.subcategories && category.subcategories.length > 0 && (
|
{category.subcategories && category.subcategories.length > 0 && (
|
||||||
@@ -59,11 +74,11 @@ const CategoryDropdown = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubCategoryMenu
|
<SubCategoryMenu
|
||||||
category={category}
|
category={category}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
position={dropdownPosition}
|
position={dropdownPosition}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
|
import { CustomCategory } from '../type';
|
||||||
import Categories from './categories';
|
import Categories from './categories';
|
||||||
import SearchInput from './search-input';
|
import SearchInput from './search-input';
|
||||||
|
|
||||||
interface SearchFiltersProps {
|
interface SearchFiltersProps {
|
||||||
data: any;
|
data: CustomCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchFilters = ({ data }: SearchFiltersProps) => {
|
const SearchFilters = ({ data }: SearchFiltersProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full">
|
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full">
|
||||||
<SearchInput />
|
<SearchInput data={data} />
|
||||||
|
|
||||||
<Categories data={data} />
|
<div className="hidden lg:block">
|
||||||
|
<Categories data={data} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ListFilterIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { SearchIcon } from 'lucide-react';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
import CategoriesSidebar from './categories-sidebar';
|
||||||
|
|
||||||
|
import { CustomCategory } from '../type';
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
data: CustomCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchInput = ({ disabled }: SearchInputProps) => {
|
const SearchInput = ({ disabled, data }: SearchInputProps) => {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<div className='relative w-full'>
|
<CategoriesSidebar
|
||||||
<SearchIcon className='absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-500' />
|
data={data}
|
||||||
|
open={isSidebarOpen}
|
||||||
|
onOpenChange={setIsSidebarOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input className='pl-8' placeholder='Search products' disabled={disabled} />
|
<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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="elevated"
|
||||||
|
className="size-12 shrink-0 flex lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<ListFilterIcon />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Category } from '@/payload-types';
|
import { Category } from '@/payload-types';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { CustomCategory } from '../type';
|
||||||
|
|
||||||
interface SubCategoryMenuProps {
|
interface SubCategoryMenuProps {
|
||||||
category: Category;
|
category: CustomCategory;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
position: {
|
position: {
|
||||||
top: number;
|
top: number;
|
||||||
@@ -43,7 +45,7 @@ const SubCategoryMenu = ({
|
|||||||
{category.subcategories.map((subcategory: Category) => (
|
{category.subcategories.map((subcategory: Category) => (
|
||||||
<Link
|
<Link
|
||||||
key={subcategory.id}
|
key={subcategory.id}
|
||||||
href="/"
|
href={`/${category.slug}/${subcategory.slug}`}
|
||||||
className="w-full text-left p-4 hover:bg-black hover:text-white flex justify-between items-center underline font-medium"
|
className="w-full text-left p-4 hover:bg-black hover:text-white flex justify-between items-center underline font-medium"
|
||||||
>
|
>
|
||||||
{subcategory.name}
|
{subcategory.name}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RefObject } from "react";
|
|||||||
|
|
||||||
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
|
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
|
||||||
const getDropdownPosition = () => {
|
const getDropdownPosition = () => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return { top: 0, left: 0 };
|
||||||
|
|
||||||
const rect = ref.current.getBoundingClientRect();
|
const rect = ref.current.getBoundingClientRect();
|
||||||
const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px)
|
const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px)
|
||||||
@@ -26,6 +26,8 @@ const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<H
|
|||||||
|
|
||||||
return { top, left };
|
return { top, left };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getDropdownPosition }
|
return { getDropdownPosition }
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Category } from "@/payload-types";
|
||||||
|
|
||||||
|
export type CustomCategory = Category & {
|
||||||
|
subcategories: Category[];
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
import { getPayload } from "payload";
|
||||||
|
import config from '@payload-config'
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
name: "All",
|
||||||
|
slug: "all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Business & Money",
|
||||||
|
color: "#FFB347",
|
||||||
|
slug: "business-money",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Accounting", slug: "accounting" },
|
||||||
|
{
|
||||||
|
name: "Entrepreneurship",
|
||||||
|
slug: "entrepreneurship",
|
||||||
|
},
|
||||||
|
{ name: "Gigs & Side Projects", slug: "gigs-side-projects" },
|
||||||
|
{ name: "Investing", slug: "investing" },
|
||||||
|
{ name: "Management & Leadership", slug: "management-leadership" },
|
||||||
|
{
|
||||||
|
name: "Marketing & Sales",
|
||||||
|
slug: "marketing-sales",
|
||||||
|
},
|
||||||
|
{ name: "Networking, Careers & Jobs", slug: "networking-careers-jobs" },
|
||||||
|
{ name: "Personal Finance", slug: "personal-finance" },
|
||||||
|
{ name: "Real Estate", slug: "real-estate" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Software Development",
|
||||||
|
color: "#7EC8E3",
|
||||||
|
slug: "software-development",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Web Development", slug: "web-development" },
|
||||||
|
{ name: "Mobile Development", slug: "mobile-development" },
|
||||||
|
{ name: "Game Development", slug: "game-development" },
|
||||||
|
{ name: "Programming Languages", slug: "programming-languages" },
|
||||||
|
{ name: "DevOps", slug: "devops" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Writing & Publishing",
|
||||||
|
color: "#D8B5FF",
|
||||||
|
slug: "writing-publishing",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Fiction", slug: "fiction" },
|
||||||
|
{ name: "Non-Fiction", slug: "non-fiction" },
|
||||||
|
{ name: "Blogging", slug: "blogging" },
|
||||||
|
{ name: "Copywriting", slug: "copywriting" },
|
||||||
|
{ name: "Self-Publishing", slug: "self-publishing" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Other",
|
||||||
|
slug: "other",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Education",
|
||||||
|
color: "#FFE066",
|
||||||
|
slug: "education",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Online Courses", slug: "online-courses" },
|
||||||
|
{ name: "Tutoring", slug: "tutoring" },
|
||||||
|
{ name: "Test Preparation", slug: "test-preparation" },
|
||||||
|
{ name: "Language Learning", slug: "language-learning" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Self Improvement",
|
||||||
|
color: "#96E6B3",
|
||||||
|
slug: "self-improvement",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Productivity", slug: "productivity" },
|
||||||
|
{ name: "Personal Development", slug: "personal-development" },
|
||||||
|
{ name: "Mindfulness", slug: "mindfulness" },
|
||||||
|
{ name: "Career Growth", slug: "career-growth" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fitness & Health",
|
||||||
|
color: "#FF9AA2",
|
||||||
|
slug: "fitness-health",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Workout Plans", slug: "workout-plans" },
|
||||||
|
{ name: "Nutrition", slug: "nutrition" },
|
||||||
|
{ name: "Mental Health", slug: "mental-health" },
|
||||||
|
{ name: "Yoga", slug: "yoga" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Design",
|
||||||
|
color: "#B5B9FF",
|
||||||
|
slug: "design",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "UI/UX", slug: "ui-ux" },
|
||||||
|
{ name: "Graphic Design", slug: "graphic-design" },
|
||||||
|
{ name: "3D Modeling", slug: "3d-modeling" },
|
||||||
|
{ name: "Typography", slug: "typography" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Drawing & Painting",
|
||||||
|
color: "#FFCAB0",
|
||||||
|
slug: "drawing-painting",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Watercolor", slug: "watercolor" },
|
||||||
|
{ name: "Acrylic", slug: "acrylic" },
|
||||||
|
{ name: "Oil", slug: "oil" },
|
||||||
|
{ name: "Pastel", slug: "pastel" },
|
||||||
|
{ name: "Charcoal", slug: "charcoal" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Music",
|
||||||
|
color: "#FFD700",
|
||||||
|
slug: "music",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Songwriting", slug: "songwriting" },
|
||||||
|
{ name: "Music Production", slug: "music-production" },
|
||||||
|
{ name: "Music Theory", slug: "music-theory" },
|
||||||
|
{ name: "Music History", slug: "music-history" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Photography",
|
||||||
|
color: "#FF6B6B",
|
||||||
|
slug: "photography",
|
||||||
|
subcategories: [
|
||||||
|
{ name: "Portrait", slug: "portrait" },
|
||||||
|
{ name: "Landscape", slug: "landscape" },
|
||||||
|
{ name: "Street Photography", slug: "street-photography" },
|
||||||
|
{ name: "Nature", slug: "nature" },
|
||||||
|
{ name: "Macro", slug: "macro" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const seed = async () => {
|
||||||
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const { name, slug, color } = category;
|
||||||
|
const categoryDoc = await payload.create({
|
||||||
|
collection: "categories",
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
color,
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subCategory of category.subcategories || []) {
|
||||||
|
await payload.create({
|
||||||
|
collection: "categories",
|
||||||
|
data: {
|
||||||
|
name: subCategory.name,
|
||||||
|
slug: subCategory.slug,
|
||||||
|
parent: categoryDoc.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seed();
|
||||||
|
console.log("Seed completed successfully.");
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding the database:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user