diff --git a/package.json b/package.json index 2a266d0..c42a2b0 100644 --- a/package.json +++ b/package.json @@ -1,79 +1,80 @@ { - "name": "multitenant-ecommerce", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "generate:types": "payload generate:types", - "db:fresh": "payload migrate:fresh" - }, - "dependencies": { - "@hookform/resolvers": "^5.0.1", - "@payloadcms/db-mongodb": "^3.36.0", - "@payloadcms/next": "^3.36.0", - "@payloadcms/payload-cloud": "^3.36.0", - "@payloadcms/richtext-lexical": "^3.36.0", - "@radix-ui/react-accordion": "^1.2.8", - "@radix-ui/react-alert-dialog": "^1.1.11", - "@radix-ui/react-aspect-ratio": "^1.1.4", - "@radix-ui/react-avatar": "^1.1.7", - "@radix-ui/react-checkbox": "^1.2.3", - "@radix-ui/react-collapsible": "^1.1.8", - "@radix-ui/react-context-menu": "^2.2.12", - "@radix-ui/react-dialog": "^1.1.11", - "@radix-ui/react-dropdown-menu": "^2.1.12", - "@radix-ui/react-hover-card": "^1.1.11", - "@radix-ui/react-label": "^2.1.4", - "@radix-ui/react-menubar": "^1.1.12", - "@radix-ui/react-navigation-menu": "^1.2.10", - "@radix-ui/react-popover": "^1.1.11", - "@radix-ui/react-progress": "^1.1.4", - "@radix-ui/react-radio-group": "^1.3.4", - "@radix-ui/react-scroll-area": "^1.2.6", - "@radix-ui/react-select": "^2.2.2", - "@radix-ui/react-separator": "^1.1.4", - "@radix-ui/react-slider": "^1.3.2", - "@radix-ui/react-slot": "^1.2.0", - "@radix-ui/react-switch": "^1.2.2", - "@radix-ui/react-tabs": "^1.1.9", - "@radix-ui/react-toggle": "^1.1.6", - "@radix-ui/react-toggle-group": "^1.1.7", - "@radix-ui/react-tooltip": "^1.2.4", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "embla-carousel-react": "^8.6.0", - "graphql": "^16.8.1", - "input-otp": "^1.4.2", - "lucide-react": "^0.503.0", - "next": "15.2.4", - "next-themes": "^0.4.6", - "payload": "^3.36.0", - "react": "^19.0.0", - "react-day-picker": "8.10.1", - "react-dom": "^19.0.0", - "react-hook-form": "^7.56.1", - "react-resizable-panels": "^2.1.9", - "recharts": "^2.15.3", - "sonner": "^2.0.3", - "tailwind-merge": "^3.2.0", - "tw-animate-css": "1.2.5", - "vaul": "^1.1.2", - "zod": "^3.24.3" - }, - "devDependencies": { - "typescript": "^5", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", - "eslint": "^9", - "eslint-config-next": "15.2.4", - "@eslint/eslintrc": "^3" - } + "name": "multitenant-ecommerce", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "generate:types": "payload generate:types", + "db:fresh": "payload migrate:fresh", + "db:seed": "bun run src/seed.ts" + }, + "dependencies": { + "@hookform/resolvers": "^5.0.1", + "@payloadcms/db-mongodb": "^3.36.0", + "@payloadcms/next": "^3.36.0", + "@payloadcms/payload-cloud": "^3.36.0", + "@payloadcms/richtext-lexical": "^3.36.0", + "@radix-ui/react-accordion": "^1.2.8", + "@radix-ui/react-alert-dialog": "^1.1.11", + "@radix-ui/react-aspect-ratio": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.7", + "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-collapsible": "^1.1.8", + "@radix-ui/react-context-menu": "^2.2.12", + "@radix-ui/react-dialog": "^1.1.11", + "@radix-ui/react-dropdown-menu": "^2.1.12", + "@radix-ui/react-hover-card": "^1.1.11", + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-menubar": "^1.1.12", + "@radix-ui/react-navigation-menu": "^1.2.10", + "@radix-ui/react-popover": "^1.1.11", + "@radix-ui/react-progress": "^1.1.4", + "@radix-ui/react-radio-group": "^1.3.4", + "@radix-ui/react-scroll-area": "^1.2.6", + "@radix-ui/react-select": "^2.2.2", + "@radix-ui/react-separator": "^1.1.4", + "@radix-ui/react-slider": "^1.3.2", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.9", + "@radix-ui/react-toggle": "^1.1.6", + "@radix-ui/react-toggle-group": "^1.1.7", + "@radix-ui/react-tooltip": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "graphql": "^16.8.1", + "input-otp": "^1.4.2", + "lucide-react": "^0.503.0", + "next": "15.2.4", + "next-themes": "^0.4.6", + "payload": "^3.36.0", + "react": "^19.0.0", + "react-day-picker": "8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.56.1", + "react-resizable-panels": "^2.1.9", + "recharts": "^2.15.3", + "sonner": "^2.0.3", + "tailwind-merge": "^3.2.0", + "tw-animate-css": "1.2.5", + "vaul": "^1.1.2", + "zod": "^3.24.3" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@tailwindcss/postcss": "^4", + "tailwindcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.2.4", + "@eslint/eslintrc": "^3" + } } diff --git a/src/app/(app)/(home)/layout.tsx b/src/app/(app)/(home)/layout.tsx index f815a20..ce8a84a 100644 --- a/src/app/(app)/(home)/layout.tsx +++ b/src/app/(app)/(home)/layout.tsx @@ -6,6 +6,7 @@ import Navbar from './navbar'; import Footer from './footer'; import SearchFilters from './search-filters'; import { Category } from '@/payload-types'; +import { CustomCategory } from './type'; interface LayoutProps { children: ReactNode; @@ -24,9 +25,10 @@ const Layout = async ({ children }: LayoutProps) => { exists: false, }, }, + sort: 'name', }); - const formattedData = data.docs.map((category) => ({ + const formattedData: CustomCategory[] = data.docs.map((category) => ({ ...category, subcategories: (category.subcategories?.docs ?? []).map((doc) => ({ ...(doc as Category), diff --git a/src/app/(app)/(home)/search-filters/categories-sidebar.tsx b/src/app/(app)/(home)/search-filters/categories-sidebar.tsx new file mode 100644 index 0000000..b914038 --- /dev/null +++ b/src/app/(app)/(home)/search-filters/categories-sidebar.tsx @@ -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(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 ( + + + + Categories + + + + {parentCategories && ( + + )} + + {currentCategories.map((category) => ( + + ))} + + + + ); +}; + +export default CategoriesSidebar; diff --git a/src/app/(app)/(home)/search-filters/categories.tsx b/src/app/(app)/(home)/search-filters/categories.tsx index 041f9c3..10d5a9d 100644 --- a/src/app/(app)/(home)/search-filters/categories.tsx +++ b/src/app/(app)/(home)/search-filters/categories.tsx @@ -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 { CustomCategory } from '../type'; +import CategoriesSidebar from './categories-sidebar'; + interface CategoriesProps { - data: any; + data: CustomCategory[]; } const Categories = ({ data }: CategoriesProps) => { + const containerRef = useRef(null); + const measureRef = useRef(null); + const viewAllRef = useRef(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 (
-
- {data.map((category: Category) => ( + {/* Categories sidebar */} + + + {/* Hidden div to measure all items */} +
+ {data.map((category) => (
))}
+ + {/* Visible items */} +
setIsAnyHovered(true)} + onMouseLeave={() => setIsAnyHovered(false)} + > + {data.slice(0, visibleCount).map((category) => ( +
+ +
+ ))} + +
+ +
+
); }; diff --git a/src/app/(app)/(home)/search-filters/category-dropdown.tsx b/src/app/(app)/(home)/search-filters/category-dropdown.tsx index a1cd9d4..ffe4819 100644 --- a/src/app/(app)/(home)/search-filters/category-dropdown.tsx +++ b/src/app/(app)/(home)/search-filters/category-dropdown.tsx @@ -1,14 +1,17 @@ 'use client'; +import { useRef, useState } from 'react'; + 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'; +import { CustomCategory } from '../type'; +import Link from 'next/link'; + interface CategoryDropdownProps { - category: Category; + category: CustomCategory; isActive?: boolean; isNavigationHovered?: boolean; } @@ -21,7 +24,14 @@ const CategoryDropdown = ({ const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); 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 = () => { if (category.subcategories) { @@ -37,16 +47,21 @@ const CategoryDropdown = ({ ref={dropdownRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} + // onClick={toggleDropdown} >
{category.subcategories && category.subcategories.length > 0 && ( @@ -59,11 +74,11 @@ const CategoryDropdown = ({ )}
- +
); }; diff --git a/src/app/(app)/(home)/search-filters/index.tsx b/src/app/(app)/(home)/search-filters/index.tsx index d01bd88..3b36717 100644 --- a/src/app/(app)/(home)/search-filters/index.tsx +++ b/src/app/(app)/(home)/search-filters/index.tsx @@ -1,16 +1,19 @@ +import { CustomCategory } from '../type'; import Categories from './categories'; import SearchInput from './search-input'; interface SearchFiltersProps { - data: any; + data: CustomCategory[]; } const SearchFilters = ({ data }: SearchFiltersProps) => { return (
- + - +
+ +
); }; diff --git a/src/app/(app)/(home)/search-filters/search-input.tsx b/src/app/(app)/(home)/search-filters/search-input.tsx index 66f8c77..3653818 100644 --- a/src/app/(app)/(home)/search-filters/search-input.tsx +++ b/src/app/(app)/(home)/search-filters/search-input.tsx @@ -1,19 +1,49 @@ +'use client'; + +import { useState } from 'react'; +import { ListFilterIcon, SearchIcon } from 'lucide-react'; + 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 { disabled?: boolean; + data: CustomCategory[]; } -const SearchInput = ({ disabled }: SearchInputProps) => { +const SearchInput = ({ disabled, data }: SearchInputProps) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + return (
-
- + - -
+
+ + + +
+ +
); diff --git a/src/app/(app)/(home)/search-filters/subcategory-menu.tsx b/src/app/(app)/(home)/search-filters/subcategory-menu.tsx index c6bccb8..db19ddf 100644 --- a/src/app/(app)/(home)/search-filters/subcategory-menu.tsx +++ b/src/app/(app)/(home)/search-filters/subcategory-menu.tsx @@ -1,8 +1,10 @@ import { Category } from '@/payload-types'; import Link from 'next/link'; +import { CustomCategory } from '../type'; + interface SubCategoryMenuProps { - category: Category; + category: CustomCategory; isOpen: boolean; position: { top: number; @@ -43,7 +45,7 @@ const SubCategoryMenu = ({ {category.subcategories.map((subcategory: Category) => ( {subcategory.name} diff --git a/src/app/(app)/(home)/search-filters/use-dropdown-position.ts b/src/app/(app)/(home)/search-filters/use-dropdown-position.ts index 9746ae7..006b1d6 100644 --- a/src/app/(app)/(home)/search-filters/use-dropdown-position.ts +++ b/src/app/(app)/(home)/search-filters/use-dropdown-position.ts @@ -2,7 +2,7 @@ import { RefObject } from "react"; const useDropdownPosition = (ref: RefObject | RefObject) => { const getDropdownPosition = () => { - if (!ref.current) return; + if (!ref.current) return { top: 0, left: 0 }; const rect = ref.current.getBoundingClientRect(); const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px) @@ -26,6 +26,8 @@ const useDropdownPosition = (ref: RefObject | RefObject { + 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); +}