feat: complete categories feature

This commit is contained in:
2025-05-01 17:18:53 +07:00
parent 7d2a24310e
commit 3572b3a415
11 changed files with 553 additions and 106 deletions
+78 -77
View File
@@ -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"
}
}
+3 -1
View File
@@ -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),
@@ -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 { CustomCategory } from '../type';
import CategoriesSidebar from './categories-sidebar';
interface CategoriesProps {
data: any;
data: CustomCategory[];
}
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 (
<div className="relative w-full">
<div className="flex flex-nowrap items-center">
{data.map((category: Category) => (
{/* Categories sidebar */}
<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}>
<CategoryDropdown
category={category}
isActive={false}
isActive={activeCategory === category.slug}
isNavigationHovered={false}
/>
</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>
);
};
@@ -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<HTMLDivElement>(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}
>
<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'
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>
{category.subcategories && category.subcategories.length > 0 && (
@@ -59,11 +74,11 @@ const CategoryDropdown = ({
)}
</div>
<SubCategoryMenu
category={category}
isOpen={isOpen}
position={dropdownPosition}
/>
<SubCategoryMenu
category={category}
isOpen={isOpen}
position={dropdownPosition}
/>
</div>
);
};
@@ -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 (
<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>
);
};
@@ -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 (
<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' />
<CategoriesSidebar
data={data}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<Input className='pl-8' placeholder='Search products' disabled={disabled} />
</div>
<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>
<Button
variant="elevated"
className="size-12 shrink-0 flex lg:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<ListFilterIcon />
</Button>
</div>
</div>
);
@@ -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) => (
<Link
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"
>
{subcategory.name}
@@ -2,7 +2,7 @@ import { RefObject } from "react";
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
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<HTMLDivElement | null> | RefObject<H
return { top, left };
}
return { top, left };
}
return { getDropdownPosition }
+5
View File
@@ -0,0 +1,5 @@
import { Category } from "@/payload-types";
export type CustomCategory = Category & {
subcategories: Category[];
}
+176
View File
@@ -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);
}