feat: add category pages

This commit is contained in:
2025-07-19 21:37:09 +07:00
parent 7ce5f8d4aa
commit 2522dd6584
16 changed files with 862 additions and 746 deletions
@@ -0,0 +1,14 @@
interface Props {
params: Promise<{
category: string;
subcategory: string;
}>;
}
const Page = async ({ params }: Props) => {
const { category, subcategory } = await params;
return <div>Subcategory Page: {subcategory} in Category: {category}</div>;
};
export default Page;
+13
View File
@@ -0,0 +1,13 @@
interface Props {
params: Promise<{
category: string;
}>;
}
const Page = async ({ params }: Props) => {
const { category } = await params;
return <div>Category Page: {category}</div>;
};
export default Page;
+3 -3
View File
@@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server'; import { getQueryClient, trpc } from '@/trpc/server';
import Navbar from './navbar'; import Navbar from '@/modules/home/ui/components/navbar';
import Footer from './footer'; import Footer from '@/modules/home/ui/components/footer';
import SearchFilters, { SearchFiltersSkeleton } from './search-filters'; import SearchFilters, { SearchFiltersSkeleton } from '@/modules/home/ui/components/search-filters';
interface LayoutProps { interface LayoutProps {
children: ReactNode; children: ReactNode;
@@ -1,37 +0,0 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useTRPC } from '@/trpc/client';
import Categories from './categories';
import SearchInput from './search-input';
const SearchFilters = () => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
return (
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]">
<SearchInput />
<div className="hidden lg:block">
<Categories data={data} />
</div>
</div>
);
};
export const SearchFiltersSkeleton = () => {
return (
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]">
<SearchInput disabled />
<div className="hidden lg:block">
<div className="h-11" />
</div>
</div>
);
};
export default SearchFilters;
@@ -0,0 +1 @@
export const DEFAULT_BG_COLOR = '#F5F5F5';
@@ -1,11 +1,11 @@
const Footer = () => { const Footer = () => {
return ( return (
<footer className="flex border-t justify-between font-medium p-6"> <footer className="flex border-t justify-between font-medium p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p>funroad, Inc.</p> <p>funroad, Inc.</p>
</div> </div>
</footer> </footer>
) )
} }
export default Footer export default Footer
@@ -1,65 +1,65 @@
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import Link from 'next/link'; import Link from 'next/link';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
interface NavbarItem { interface NavbarItem {
href: string; href: string;
children: ReactNode; children: ReactNode;
} }
interface Props { interface Props {
items: NavbarItem[]; items: NavbarItem[];
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const NavbarSidebar = ({ items, open, onOpenChange }: Props) => { const NavbarSidebar = ({ items, open, onOpenChange }: Props) => {
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="p-0 transition-none"> <SheetContent side="left" className="p-0 transition-none">
<SheetHeader className="p-4 border-b"> <SheetHeader className="p-4 border-b">
<SheetTitle>Menu</SheetTitle> <SheetTitle>Menu</SheetTitle>
</SheetHeader> </SheetHeader>
<ScrollArea className="flex flex-col overflow-y-auto h-full pb-2"> <ScrollArea className="flex flex-col overflow-y-auto h-full pb-2">
{items.map((item) => ( {items.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium" className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
{item.children} {item.children}
</Link> </Link>
))} ))}
<div className="border-t"> <div className="border-t">
<Link <Link
href="/sign-in" href="/sign-in"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium" className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Log in Log in
</Link> </Link>
<Link <Link
href="/sign-up" href="/sign-up"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium" className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Start Selling Start Selling
</Link> </Link>
</div> </div>
</ScrollArea> </ScrollArea>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
}; };
export default NavbarSidebar; export default NavbarSidebar;
@@ -1,147 +1,147 @@
'use client'; 'use client';
import { ReactNode, useState } from 'react'; import { ReactNode, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Poppins } from 'next/font/google'; import { Poppins } from 'next/font/google';
import { MenuIcon } from 'lucide-react'; import { MenuIcon } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client'; import { useTRPC } from '@/trpc/client';
import NavbarSidebar from './navbar-sidebar'; import NavbarSidebar from './navbar-sidebar';
const poppins = Poppins({ const poppins = Poppins({
subsets: ['latin'], subsets: ['latin'],
weight: ['700'], weight: ['700'],
}); });
interface NavbarItemProps { interface NavbarItemProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
isActive?: boolean; isActive?: boolean;
} }
const navbarItems = [ const navbarItems = [
{ {
href: '/', href: '/',
children: 'Home', children: 'Home',
}, },
{ {
href: '/about', href: '/about',
children: 'About', children: 'About',
}, },
{ {
href: '/features', href: '/features',
children: 'Features', children: 'Features',
}, },
{ {
href: '/pricing', href: '/pricing',
children: 'Pricing', children: 'Pricing',
}, },
{ {
href: '/contact', href: '/contact',
children: 'Contact', children: 'Contact',
}, },
]; ];
const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => { const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => {
return ( return (
<Button <Button
asChild asChild
variant="outline" variant="outline"
className={cn( className={cn(
'bg-transparent hover:bg-transparent rounded-full hover:border-primary border-transparent px-3.5 text-lg', 'bg-transparent hover:bg-transparent rounded-full hover:border-primary border-transparent px-3.5 text-lg',
isActive && 'bg-black text-white hover:bg-black hover:text-white' isActive && 'bg-black text-white hover:bg-black hover:text-white'
)} )}
> >
<Link href={href}>{children}</Link> <Link href={href}>{children}</Link>
</Button> </Button>
); );
}; };
const Navbar = () => { const Navbar = () => {
const pathname = usePathname(); const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC(); const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions()); const session = useQuery(trpc.auth.session.queryOptions());
return ( return (
<nav className="h-20 flex border-b justify-between font-medium bg-white"> <nav className="h-20 flex border-b justify-between font-medium bg-white">
<Link href="/" className="pl-6 flex items-center"> <Link href="/" className="pl-6 flex items-center">
<span className={cn('text-5xl font-semibold', poppins.className)}> <span className={cn('text-5xl font-semibold', poppins.className)}>
funroad funroad
</span> </span>
</Link> </Link>
<NavbarSidebar <NavbarSidebar
items={navbarItems} items={navbarItems}
open={isSidebarOpen} open={isSidebarOpen}
onOpenChange={setIsSidebarOpen} onOpenChange={setIsSidebarOpen}
/> />
<div className="items-center gap-4 hidden lg:flex"> <div className="items-center gap-4 hidden lg:flex">
{navbarItems.map((item) => ( {navbarItems.map((item) => (
<NavbarItem <NavbarItem
key={item.href} key={item.href}
href={item.href} href={item.href}
isActive={pathname === item.href} isActive={pathname === item.href}
> >
{item.children} {item.children}
</NavbarItem> </NavbarItem>
))} ))}
</div> </div>
{session.data?.user ? ( {session.data?.user ? (
<div className='hidden lg:flex'> <div className='hidden lg:flex'>
<Button <Button
asChild asChild
variant="secondary" variant="secondary"
className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg" className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg"
> >
<Link href="/admin"> <Link href="/admin">
Dashboard Dashboard
</Link> </Link>
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="hidden lg:flex"> <div className="hidden lg:flex">
<Button <Button
asChild asChild
variant="secondary" variant="secondary"
className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-white hover:bg-pink-400 transition-colors text-lg" className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-white hover:bg-pink-400 transition-colors text-lg"
> >
<Link prefetch href="/sign-in"> <Link prefetch href="/sign-in">
Log in Log in
</Link> </Link>
</Button> </Button>
<Button <Button
asChild asChild
variant="secondary" variant="secondary"
className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg" className="border-l border-t-0 border-b-0 border-r-0 px-12 h-full rounded-none bg-black text-white hover:bg-pink-400 hover:text-black transition-colors text-lg"
> >
<Link prefetch href="/sign-up"> <Link prefetch href="/sign-up">
Start Selling Start Selling
</Link> </Link>
</Button> </Button>
</div> </div>
)} )}
<div className="flex lg:hidden items-center justify-center"> <div className="flex lg:hidden items-center justify-center">
<Button <Button
variant="ghost" variant="ghost"
className="size-12 border-transparent bg-white" className="size-12 border-transparent bg-white"
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
> >
<MenuIcon /> <MenuIcon />
</Button> </Button>
</div> </div>
</nav> </nav>
); );
}; };
export default Navbar; export default Navbar;
@@ -0,0 +1,56 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import Link from 'next/link';
interface Props {
activeCategoryName?: string | null;
activeCategory?: string | null;
activeSubCategoryName?: string | null;
}
export const BreadcrumbNavigation = ({
activeCategory,
activeCategoryName,
activeSubCategoryName,
}: Props) => {
if (!activeCategoryName || activeCategory === 'all') return null;
return (
<Breadcrumb>
<BreadcrumbList>
{activeSubCategoryName ? (
<>
<BreadcrumbItem>
<BreadcrumbLink
asChild
className="text-xl font-medium underline text-primary"
>
<Link href={`/${activeCategory}`}>{activeCategoryName}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="text-primary font-medium text-lg">
/
</BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbPage className="text-xl font-medium">
{activeSubCategoryName}
</BreadcrumbPage>
</BreadcrumbItem>
</>
) : (
<BreadcrumbItem>
<BreadcrumbPage className="text-xl font-medium">
{activeCategoryName}
</BreadcrumbPage>
</BreadcrumbItem>
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -1,113 +1,113 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useTRPC } from '@/trpc/client'; import { useTRPC } from '@/trpc/client';
import { CategoriesGetManyOutput } from '@/modules/categories/type'; import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesSidebarProps { interface CategoriesSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const CategoriesSidebar = ({ const CategoriesSidebar = ({
open, open,
onOpenChange, onOpenChange,
}: CategoriesSidebarProps) => { }: CategoriesSidebarProps) => {
const trpc = useTRPC(); const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions()); const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const router = useRouter(); const router = useRouter();
const [parentCategories, setParentCategories] = useState< const [parentCategories, setParentCategories] = useState<
CategoriesGetManyOutput | null CategoriesGetManyOutput | null
>(null); >(null);
const [selectedCategory, setSelectedCategory] = const [selectedCategory, setSelectedCategory] =
useState<CategoriesGetManyOutput[1] | null>(null); useState<CategoriesGetManyOutput[1] | null>(null);
// If we have parent categories, show those, otherwise show root categories // If we have parent categories, show those, otherwise show root categories
const currentCategories = parentCategories ?? data ?? []; const currentCategories = parentCategories ?? data ?? [];
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setSelectedCategory(null); setSelectedCategory(null);
setParentCategories(null); setParentCategories(null);
onOpenChange(open); onOpenChange(open);
}; };
const handleCategoryClick = (category: CategoriesGetManyOutput[1]) => { const handleCategoryClick = (category: CategoriesGetManyOutput[1]) => {
if (category.subcategories && category.subcategories.length > 0) { if (category.subcategories && category.subcategories.length > 0) {
setParentCategories(category.subcategories as CategoriesGetManyOutput); setParentCategories(category.subcategories as CategoriesGetManyOutput);
setSelectedCategory(category); setSelectedCategory(category);
} else { } else {
// This is a leaf category (no subcategories) // This is a leaf category (no subcategories)
if (parentCategories && selectedCategory) { if (parentCategories && selectedCategory) {
router.push(`/${selectedCategory.slug}/${category.slug}`); router.push(`/${selectedCategory.slug}/${category.slug}`);
} else { } else {
if (category.slug === 'all') { if (category.slug === 'all') {
router.push('/'); router.push('/');
} else { } else {
router.push(`/${category.slug}`); router.push(`/${category.slug}`);
} }
} }
handleOpenChange(false); handleOpenChange(false);
} }
}; };
const handleBackClick = () => { const handleBackClick = () => {
if (parentCategories) { if (parentCategories) {
setParentCategories(null); setParentCategories(null);
setSelectedCategory(null); setSelectedCategory(null);
} }
}; };
const backgroundColor = selectedCategory?.color || 'white'; const backgroundColor = selectedCategory?.color || 'white';
return ( return (
<Sheet open={open} onOpenChange={handleOpenChange}> <Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent <SheetContent
side="left" side="left"
className="p-0 transition-none" className="p-0 transition-none"
style={{ backgroundColor }} style={{ backgroundColor }}
> >
<SheetHeader className="p-4 border-b"> <SheetHeader className="p-4 border-b">
<SheetTitle>Categories</SheetTitle> <SheetTitle>Categories</SheetTitle>
</SheetHeader> </SheetHeader>
<ScrollArea className="flex flex-col overflow-y-auto h-full pb-2"> <ScrollArea className="flex flex-col overflow-y-auto h-full pb-2">
{parentCategories && ( {parentCategories && (
<button <button
onClick={handleBackClick} onClick={handleBackClick}
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium cursor-pointer" 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" /> <ChevronLeftIcon className="size-4 mr-2" />
Back Back
</button> </button>
)} )}
{currentCategories.map((category) => ( {currentCategories.map((category) => (
<button <button
key={category.slug} key={category.slug}
onClick={() => handleCategoryClick(category)} 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" 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.name}
{category.subcategories && category.subcategories.length > 0 && ( {category.subcategories && category.subcategories.length > 0 && (
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
)} )}
</button> </button>
))} ))}
</ScrollArea> </ScrollArea>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
}; };
export default CategoriesSidebar; export default CategoriesSidebar;
@@ -1,127 +1,132 @@
'use client'; 'use client';
import { ListFilterIcon } from 'lucide-react'; import { useParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react'; import { ListFilterIcon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
import CategoryDropdown from './category-dropdown'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import CategoriesSidebar from './categories-sidebar';
import { CategoriesGetManyOutput } from '@/modules/categories/type'; import CategoryDropdown from './category-dropdown';
import CategoriesSidebar from './categories-sidebar';
interface CategoriesProps {
data: CategoriesGetManyOutput; interface CategoriesProps {
} data: CategoriesGetManyOutput;
}
const Categories = ({ data }: CategoriesProps) => {
const containerRef = useRef<HTMLDivElement>(null); const Categories = ({ data }: CategoriesProps) => {
const measureRef = useRef<HTMLDivElement>(null); const params = useParams();
const viewAllRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(data.length); const measureRef = useRef<HTMLDivElement>(null);
const [isAnyHovered, setIsAnyHovered] = useState(false); const viewAllRef = useRef<HTMLDivElement>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [visibleCount, setVisibleCount] = useState(data.length);
const activeCategory = 'all'; const [isAnyHovered, setIsAnyHovered] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const activeCategoryIndex = data.findIndex(
(cat) => cat.slug === activeCategory const categoryParam = params.category as string | undefined;
); const activeCategory = categoryParam || 'all';
const isActiveCategoryHidden =
activeCategoryIndex >= visibleCount && activeCategoryIndex !== -1; const activeCategoryIndex = data.findIndex(
(cat) => cat.slug === activeCategory
useEffect(() => { );
const calculateVisible = () => { const isActiveCategoryHidden =
if (!containerRef.current || !measureRef.current || !viewAllRef.current) activeCategoryIndex >= visibleCount && activeCategoryIndex !== -1;
return;
useEffect(() => {
const containerWidth = containerRef.current.offsetWidth; const calculateVisible = () => {
const viewAllWidth = viewAllRef.current.offsetWidth; if (!containerRef.current || !measureRef.current || !viewAllRef.current)
const availableWidth = containerWidth - viewAllWidth; return;
const items = Array.from(measureRef.current.children); const containerWidth = containerRef.current.offsetWidth;
let totalWidth = 0; const viewAllWidth = viewAllRef.current.offsetWidth;
let visible = 0; const availableWidth = containerWidth - viewAllWidth;
for (const item of items) { const items = Array.from(measureRef.current.children);
const width = item.getBoundingClientRect().width; let totalWidth = 0;
let visible = 0;
if (totalWidth + width > availableWidth) break;
for (const item of items) {
totalWidth += width; const width = item.getBoundingClientRect().width;
visible++;
} if (totalWidth + width > availableWidth) break;
setVisibleCount(visible); totalWidth += width;
}; visible++;
}
const resizeObserver = new ResizeObserver(calculateVisible);
setVisibleCount(visible);
resizeObserver.observe(containerRef.current!); };
return () => resizeObserver.disconnect(); const resizeObserver = new ResizeObserver(calculateVisible);
}, [data.length]);
resizeObserver.observe(containerRef.current!);
return (
<div className="relative w-full"> return () => resizeObserver.disconnect();
{/* Categories sidebar */} }, [data.length]);
<CategoriesSidebar
open={isSidebarOpen} return (
onOpenChange={setIsSidebarOpen} <div className="relative w-full">
/> {/* Categories sidebar */}
<CategoriesSidebar
{/* Hidden div to measure all items */} open={isSidebarOpen}
<div onOpenChange={setIsSidebarOpen}
ref={measureRef} />
className="absolute opacity-0 pointer-events-none flex"
style={{ position: 'fixed', top: -9999, left: -9999 }} {/* Hidden div to measure all items */}
> <div
{data.map((category) => ( ref={measureRef}
<div key={category.id}> className="absolute opacity-0 pointer-events-none flex"
<CategoryDropdown style={{ position: 'fixed', top: -9999, left: -9999 }}
category={category} >
isActive={activeCategory === category.slug} {data.map((category) => (
isNavigationHovered={false} <div key={category.id}>
/> <CategoryDropdown
</div> category={category}
))} isActive={activeCategory === category.slug}
</div> isNavigationHovered={false}
/>
{/* Visible items */} </div>
<div ))}
ref={containerRef} </div>
className="flex flex-nowrap items-center"
onMouseEnter={() => setIsAnyHovered(true)} {/* Visible items */}
onMouseLeave={() => setIsAnyHovered(false)} <div
> ref={containerRef}
{data.slice(0, visibleCount).map((category) => ( className="flex flex-nowrap items-center"
<div key={category.id}> onMouseEnter={() => setIsAnyHovered(true)}
<CategoryDropdown onMouseLeave={() => setIsAnyHovered(false)}
category={category} >
isActive={activeCategory === category.slug} {data.slice(0, visibleCount).map((category) => (
isNavigationHovered={isAnyHovered} <div key={category.id}>
/> <CategoryDropdown
</div> category={category}
))} isActive={activeCategory === category.slug}
isNavigationHovered={isAnyHovered}
<div ref={viewAllRef} className="shrink-0"> />
<Button </div>
className={cn( ))}
'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
isActiveCategoryHidden && <div ref={viewAllRef} className="shrink-0">
!isAnyHovered && <Button
'bg-white border-primary' variant="elevated"
)} className={cn(
onClick={() => setIsSidebarOpen(true)} 'h-11 px-4 bg-transparent border-transparent rounded-full hover:bg-white hover:border-primary text-black',
> isActiveCategoryHidden &&
View All !isAnyHovered &&
<ListFilterIcon className="ml-2" /> 'bg-white border-primary'
</Button> )}
</div> onClick={() => setIsSidebarOpen(true)}
</div> >
</div> View All
); <ListFilterIcon className="ml-2" />
}; </Button>
</div>
export default Categories; </div>
</div>
);
};
export default Categories;
@@ -1,86 +1,86 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; 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 useDropdownPosition from './use-dropdown-position'; import useDropdownPosition from './use-dropdown-position';
import SubCategoryMenu from './subcategory-menu'; import SubCategoryMenu from './subcategory-menu';
import Link from 'next/link'; import Link from 'next/link';
import { CategoriesGetManyOutput } from '@/modules/categories/type'; import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoryDropdownProps { interface CategoryDropdownProps {
category: CategoriesGetManyOutput[1]; category: CategoriesGetManyOutput[1];
isActive?: boolean; isActive?: boolean;
isNavigationHovered?: boolean; isNavigationHovered?: boolean;
} }
const CategoryDropdown = ({ const CategoryDropdown = ({
category, category,
isActive, isActive,
isNavigationHovered, isNavigationHovered,
}: CategoryDropdownProps) => { }: CategoryDropdownProps) => {
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 // TODO: Will improve in mobile screen
// const toggleDropdown = () => { // const toggleDropdown = () => {
// if (category.subcategories?.docs?.length) { // if (category.subcategories?.docs?.length) {
// setIsOpen(!isOpen); // setIsOpen(!isOpen);
// } // }
// }; // };
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (category.subcategories) { if (category.subcategories) {
setIsOpen(true); setIsOpen(true);
} }
}; };
const handleMouseLeave = () => setIsOpen(false); const handleMouseLeave = () => setIsOpen(false);
return ( return (
<div <div
className="relative" className="relative"
ref={dropdownRef} ref={dropdownRef}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
// onClick={toggleDropdown} // 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 && isOpen &&
'bg-white border-primary shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[4px] -translate-y-[4px]' 'bg-white border-primary shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[4px] -translate-y-[4px]'
)} )}
> >
<Link href={`/${category.slug === 'all' ? '' : category.slug}`}> <Link href={`/${category.slug === 'all' ? '' : category.slug}`}>
{category.name} {category.name}
</Link> </Link>
</Button> </Button>
{category.subcategories && category.subcategories.length > 0 && ( {category.subcategories && category.subcategories.length > 0 && (
<div <div
className={cn( 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', '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' isOpen && 'opacity-100'
)} )}
></div> ></div>
)} )}
</div> </div>
<SubCategoryMenu <SubCategoryMenu
category={category} category={category}
isOpen={isOpen} isOpen={isOpen}
position={dropdownPosition} position={dropdownPosition}
/> />
</div> </div>
); );
}; };
export default CategoryDropdown; export default CategoryDropdown;
@@ -0,0 +1,64 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { useTRPC } from '@/trpc/client';
import Categories from './categories';
import SearchInput from './search-input';
import { DEFAULT_BG_COLOR } from '../constants';
import { BreadcrumbNavigation } from './breadcrumb-navigation';
const SearchFilters = () => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const params = useParams();
const categoryParam = params.category as string | undefined;
const activeCategory = categoryParam || 'all';
const activeCategoryData = data.find(
(category) => category.slug === activeCategory
);
const activeCategoryColor = activeCategoryData?.color || DEFAULT_BG_COLOR;
const activeCategoryName = activeCategoryData?.name || null;
const activeSubCategory = params.subcategory as string | undefined;
const activeSubCategoryName =
activeCategoryData?.subcategories.find(
(sub) => sub.slug === activeSubCategory
)?.name || null;
return (
<div
className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]"
style={{
backgroundColor: activeCategoryColor,
}}
>
<SearchInput />
<div className="hidden lg:block">
<Categories data={data} />
</div>
<BreadcrumbNavigation
activeCategory={activeCategory}
activeCategoryName={activeCategoryName}
activeSubCategoryName={activeSubCategoryName}
/>
</div>
);
};
export const SearchFiltersSkeleton = () => {
return (
<div className="px-4 lg:px-12 py-8 border-b flex flex-col gap-4 w-full bg-[#F5F5F5]">
<SearchInput disabled />
<div className="hidden lg:block">
<div className="h-11" />
</div>
</div>
);
};
export default SearchFilters;
@@ -1,63 +1,63 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BookmarkCheckIcon, ListFilterIcon, SearchIcon } from 'lucide-react'; import { BookmarkCheckIcon, ListFilterIcon, SearchIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client'; import { useTRPC } from '@/trpc/client';
import CategoriesSidebar from './categories-sidebar'; import CategoriesSidebar from './categories-sidebar';
interface SearchInputProps { interface SearchInputProps {
disabled?: boolean; disabled?: boolean;
} }
const SearchInput = ({ disabled }: SearchInputProps) => { const SearchInput = ({ disabled }: SearchInputProps) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC(); const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions()); const session = useQuery(trpc.auth.session.queryOptions());
return ( return (
<div> <div>
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<CategoriesSidebar <CategoriesSidebar
open={isSidebarOpen} open={isSidebarOpen}
onOpenChange={setIsSidebarOpen} onOpenChange={setIsSidebarOpen}
/> />
<div className="relative w-full"> <div className="relative w-full">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-500" /> <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-neutral-500" />
<Input <Input
className="pl-8" className="pl-8"
placeholder="Search products" placeholder="Search products"
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<Button <Button
variant="elevated" variant="elevated"
className="size-12 shrink-0 flex lg:hidden" className="size-12 shrink-0 flex lg:hidden"
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
> >
<ListFilterIcon /> <ListFilterIcon />
</Button> </Button>
{session.data?.user && ( {session.data?.user && (
<Button asChild variant="elevated"> <Button asChild variant="elevated">
<Link href="/library"> <Link href="/library">
<BookmarkCheckIcon /> <BookmarkCheckIcon />
Library Library
</Link> </Link>
</Button> </Button>
)} )}
</div> </div>
</div> </div>
); );
}; };
export default SearchInput; export default SearchInput;
@@ -1,60 +1,60 @@
import Link from 'next/link'; import Link from 'next/link';
import { Category } from '@/payload-types'; import { Category } from '@/payload-types';
import { CategoriesGetManyOutput } from '@/modules/categories/type'; import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface SubCategoryMenuProps { interface SubCategoryMenuProps {
category: CategoriesGetManyOutput[1]; category: CategoriesGetManyOutput[1];
isOpen: boolean; isOpen: boolean;
position: { position: {
top: number; top: number;
left: number; left: number;
}; };
} }
const SubCategoryMenu = ({ const SubCategoryMenu = ({
category, category,
isOpen, isOpen,
position, position,
}: SubCategoryMenuProps) => { }: SubCategoryMenuProps) => {
if ( if (
!isOpen || !isOpen ||
!category.subcategories || !category.subcategories ||
category.subcategories.length === 0 category.subcategories.length === 0
) { ) {
return null; return null;
} }
const backgroundColor = category.color || '#F5F5F5'; const backgroundColor = category.color || '#F5F5F5';
return ( return (
<div <div
className="fixed z-100" className="fixed z-100"
style={{ style={{
top: position?.top, top: position?.top,
left: position?.left, left: position?.left,
}} }}
> >
{/* Invisible bridge to maintain hover */} {/* Invisible bridge to maintain hover */}
<div className="h-3 w-60" /> <div className="h-3 w-60" />
<div <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]" 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 }} style={{ backgroundColor }}
> >
<div> <div>
{category.subcategories.map((subcategory: Category) => ( {category.subcategories.map((subcategory: Category) => (
<Link <Link
key={subcategory.id} key={subcategory.id}
href={`/${category.slug}/${subcategory.slug}`} 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}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default SubCategoryMenu; export default SubCategoryMenu;
@@ -1,36 +1,36 @@
import { RefObject } from "react"; 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 { top: 0, left: 0 }; 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)
// Calculate the initial position // Calculate the initial position
let left = rect.left + window.scrollX; let left = rect.left + window.scrollX;
const top = rect.bottom + window.scrollY; const top = rect.bottom + window.scrollY;
// Check if dropdown would go off the right edge of the viewport // Check if dropdown would go off the right edge of the viewport
if (left + dropdownWidth > window.innerWidth) { if (left + dropdownWidth > window.innerWidth) {
left = rect.right + window.scrollX - dropdownWidth; left = rect.right + window.scrollX - dropdownWidth;
// If still off-screen, align to the left edge of the viewport with some padding // If still off-screen, align to the left edge of the viewport with some padding
if (left < 0) { if (left < 0) {
left = window.innerWidth - dropdownWidth - 16; // 16px padding left = window.innerWidth - dropdownWidth - 16; // 16px padding
} }
if (left < 0) { if (left < 0) {
left = 16 left = 16
} }
return { top, left }; return { top, left };
} }
return { top, left }; return { top, left };
} }
return { getDropdownPosition } return { getDropdownPosition }
} }
export default useDropdownPosition; export default useDropdownPosition;