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 Navbar from './navbar';
import Footer from './footer';
import SearchFilters, { SearchFiltersSkeleton } from './search-filters';
import Navbar from '@/modules/home/ui/components/navbar';
import Footer from '@/modules/home/ui/components/footer';
import SearchFilters, { SearchFiltersSkeleton } from '@/modules/home/ui/components/search-filters';
interface LayoutProps {
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 = () => {
return (
<footer className="flex border-t justify-between font-medium p-6">
<div className="flex items-center gap-2">
<p>funroad, Inc.</p>
</div>
</footer>
)
}
const Footer = () => {
return (
<footer className="flex border-t justify-between font-medium p-6">
<div className="flex items-center gap-2">
<p>funroad, Inc.</p>
</div>
</footer>
)
}
export default Footer
@@ -1,65 +1,65 @@
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import Link from 'next/link';
import { ReactNode } from 'react';
interface NavbarItem {
href: string;
children: ReactNode;
}
interface Props {
items: NavbarItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
const NavbarSidebar = ({ items, open, onOpenChange }: Props) => {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="p-0 transition-none">
<SheetHeader className="p-4 border-b">
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<ScrollArea className="flex flex-col overflow-y-auto h-full pb-2">
{items.map((item) => (
<Link
key={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"
onClick={() => onOpenChange(false)}
>
{item.children}
</Link>
))}
<div className="border-t">
<Link
href="/sign-in"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)}
>
Log in
</Link>
<Link
href="/sign-up"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)}
>
Start Selling
</Link>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
};
export default NavbarSidebar;
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import Link from 'next/link';
import { ReactNode } from 'react';
interface NavbarItem {
href: string;
children: ReactNode;
}
interface Props {
items: NavbarItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
const NavbarSidebar = ({ items, open, onOpenChange }: Props) => {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="p-0 transition-none">
<SheetHeader className="p-4 border-b">
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<ScrollArea className="flex flex-col overflow-y-auto h-full pb-2">
{items.map((item) => (
<Link
key={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"
onClick={() => onOpenChange(false)}
>
{item.children}
</Link>
))}
<div className="border-t">
<Link
href="/sign-in"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)}
>
Log in
</Link>
<Link
href="/sign-up"
className="w-full text-left p-4 hover:bg-black hover:text-white flex items-center text-base font-medium"
onClick={() => onOpenChange(false)}
>
Start Selling
</Link>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
};
export default NavbarSidebar;
@@ -1,147 +1,147 @@
'use client';
import { ReactNode, useState } from 'react';
import Link from 'next/link';
import { Poppins } from 'next/font/google';
import { MenuIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client';
import NavbarSidebar from './navbar-sidebar';
const poppins = Poppins({
subsets: ['latin'],
weight: ['700'],
});
interface NavbarItemProps {
href: string;
children: ReactNode;
isActive?: boolean;
}
const navbarItems = [
{
href: '/',
children: 'Home',
},
{
href: '/about',
children: 'About',
},
{
href: '/features',
children: 'Features',
},
{
href: '/pricing',
children: 'Pricing',
},
{
href: '/contact',
children: 'Contact',
},
];
const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => {
return (
<Button
asChild
variant="outline"
className={cn(
'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'
)}
>
<Link href={href}>{children}</Link>
</Button>
);
};
const Navbar = () => {
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions());
return (
<nav className="h-20 flex border-b justify-between font-medium bg-white">
<Link href="/" className="pl-6 flex items-center">
<span className={cn('text-5xl font-semibold', poppins.className)}>
funroad
</span>
</Link>
<NavbarSidebar
items={navbarItems}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<div className="items-center gap-4 hidden lg:flex">
{navbarItems.map((item) => (
<NavbarItem
key={item.href}
href={item.href}
isActive={pathname === item.href}
>
{item.children}
</NavbarItem>
))}
</div>
{session.data?.user ? (
<div className='hidden lg:flex'>
<Button
asChild
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"
>
<Link href="/admin">
Dashboard
</Link>
</Button>
</div>
) : (
<div className="hidden lg:flex">
<Button
asChild
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"
>
<Link prefetch href="/sign-in">
Log in
</Link>
</Button>
<Button
asChild
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"
>
<Link prefetch href="/sign-up">
Start Selling
</Link>
</Button>
</div>
)}
<div className="flex lg:hidden items-center justify-center">
<Button
variant="ghost"
className="size-12 border-transparent bg-white"
onClick={() => setIsSidebarOpen(true)}
>
<MenuIcon />
</Button>
</div>
</nav>
);
};
export default Navbar;
'use client';
import { ReactNode, useState } from 'react';
import Link from 'next/link';
import { Poppins } from 'next/font/google';
import { MenuIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client';
import NavbarSidebar from './navbar-sidebar';
const poppins = Poppins({
subsets: ['latin'],
weight: ['700'],
});
interface NavbarItemProps {
href: string;
children: ReactNode;
isActive?: boolean;
}
const navbarItems = [
{
href: '/',
children: 'Home',
},
{
href: '/about',
children: 'About',
},
{
href: '/features',
children: 'Features',
},
{
href: '/pricing',
children: 'Pricing',
},
{
href: '/contact',
children: 'Contact',
},
];
const NavbarItem = ({ href, children, isActive }: NavbarItemProps) => {
return (
<Button
asChild
variant="outline"
className={cn(
'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'
)}
>
<Link href={href}>{children}</Link>
</Button>
);
};
const Navbar = () => {
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions());
return (
<nav className="h-20 flex border-b justify-between font-medium bg-white">
<Link href="/" className="pl-6 flex items-center">
<span className={cn('text-5xl font-semibold', poppins.className)}>
funroad
</span>
</Link>
<NavbarSidebar
items={navbarItems}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<div className="items-center gap-4 hidden lg:flex">
{navbarItems.map((item) => (
<NavbarItem
key={item.href}
href={item.href}
isActive={pathname === item.href}
>
{item.children}
</NavbarItem>
))}
</div>
{session.data?.user ? (
<div className='hidden lg:flex'>
<Button
asChild
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"
>
<Link href="/admin">
Dashboard
</Link>
</Button>
</div>
) : (
<div className="hidden lg:flex">
<Button
asChild
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"
>
<Link prefetch href="/sign-in">
Log in
</Link>
</Button>
<Button
asChild
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"
>
<Link prefetch href="/sign-up">
Start Selling
</Link>
</Button>
</div>
)}
<div className="flex lg:hidden items-center justify-center">
<Button
variant="ghost"
className="size-12 border-transparent bg-white"
onClick={() => setIsSidebarOpen(true)}
>
<MenuIcon />
</Button>
</div>
</nav>
);
};
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 { useRouter } from 'next/navigation';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTRPC } from '@/trpc/client';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CategoriesSidebar = ({
open,
onOpenChange,
}: CategoriesSidebarProps) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const router = useRouter();
const [parentCategories, setParentCategories] = useState<
CategoriesGetManyOutput | null
>(null);
const [selectedCategory, setSelectedCategory] =
useState<CategoriesGetManyOutput[1] | 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: CategoriesGetManyOutput[1]) => {
if (category.subcategories && category.subcategories.length > 0) {
setParentCategories(category.subcategories as CategoriesGetManyOutput);
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;
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTRPC } from '@/trpc/client';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CategoriesSidebar = ({
open,
onOpenChange,
}: CategoriesSidebarProps) => {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.categories.getMany.queryOptions());
const router = useRouter();
const [parentCategories, setParentCategories] = useState<
CategoriesGetManyOutput | null
>(null);
const [selectedCategory, setSelectedCategory] =
useState<CategoriesGetManyOutput[1] | 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: CategoriesGetManyOutput[1]) => {
if (category.subcategories && category.subcategories.length > 0) {
setParentCategories(category.subcategories as CategoriesGetManyOutput);
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,127 +1,132 @@
'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 CategoriesSidebar from './categories-sidebar';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoriesProps {
data: CategoriesGetManyOutput;
}
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">
{/* Categories sidebar */}
<CategoriesSidebar
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
{/* 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={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>
);
};
export default Categories;
'use client';
import { useParams } from 'next/navigation';
import { ListFilterIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import CategoryDropdown from './category-dropdown';
import CategoriesSidebar from './categories-sidebar';
interface CategoriesProps {
data: CategoriesGetManyOutput;
}
const Categories = ({ data }: CategoriesProps) => {
const params = useParams();
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 categoryParam = params.category as string | undefined;
const activeCategory = categoryParam || '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">
{/* Categories sidebar */}
<CategoriesSidebar
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
{/* 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={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
variant="elevated"
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>
);
};
export default Categories;
@@ -1,86 +1,86 @@
'use client';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import useDropdownPosition from './use-dropdown-position';
import SubCategoryMenu from './subcategory-menu';
import Link from 'next/link';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoryDropdownProps {
category: CategoriesGetManyOutput[1];
isActive?: boolean;
isNavigationHovered?: boolean;
}
const CategoryDropdown = ({
category,
isActive,
isNavigationHovered,
}: CategoryDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
const dropdownPosition = getDropdownPosition();
// TODO: Will improve in mobile screen
// const toggleDropdown = () => {
// if (category.subcategories?.docs?.length) {
// setIsOpen(!isOpen);
// }
// };
const handleMouseEnter = () => {
if (category.subcategories) {
setIsOpen(true);
}
};
const handleMouseLeave = () => setIsOpen(false);
return (
<div
className="relative"
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',
isOpen &&
'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}`}>
{category.name}
</Link>
</Button>
{category.subcategories && category.subcategories.length > 0 && (
<div
className={cn(
'opacity-0 absolute -bottom-3 w-0 h-0 border-l-[10px] border-r-[10px] border-b-[10px] border-l-transparent border-r-transparent border-b-black left-1/2 -translate-x-1/2',
isOpen && 'opacity-100'
)}
></div>
)}
</div>
<SubCategoryMenu
category={category}
isOpen={isOpen}
position={dropdownPosition}
/>
</div>
);
};
export default CategoryDropdown;
'use client';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import useDropdownPosition from './use-dropdown-position';
import SubCategoryMenu from './subcategory-menu';
import Link from 'next/link';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoryDropdownProps {
category: CategoriesGetManyOutput[1];
isActive?: boolean;
isNavigationHovered?: boolean;
}
const CategoryDropdown = ({
category,
isActive,
isNavigationHovered,
}: CategoryDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
const dropdownPosition = getDropdownPosition();
// TODO: Will improve in mobile screen
// const toggleDropdown = () => {
// if (category.subcategories?.docs?.length) {
// setIsOpen(!isOpen);
// }
// };
const handleMouseEnter = () => {
if (category.subcategories) {
setIsOpen(true);
}
};
const handleMouseLeave = () => setIsOpen(false);
return (
<div
className="relative"
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',
isOpen &&
'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}`}>
{category.name}
</Link>
</Button>
{category.subcategories && category.subcategories.length > 0 && (
<div
className={cn(
'opacity-0 absolute -bottom-3 w-0 h-0 border-l-[10px] border-r-[10px] border-b-[10px] border-l-transparent border-r-transparent border-b-black left-1/2 -translate-x-1/2',
isOpen && 'opacity-100'
)}
></div>
)}
</div>
<SubCategoryMenu
category={category}
isOpen={isOpen}
position={dropdownPosition}
/>
</div>
);
};
export default CategoryDropdown;
@@ -0,0 +1,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';
import { useState } from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { BookmarkCheckIcon, ListFilterIcon, SearchIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client';
import CategoriesSidebar from './categories-sidebar';
interface SearchInputProps {
disabled?: boolean;
}
const SearchInput = ({ disabled }: SearchInputProps) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions());
return (
<div>
<div className="flex items-center gap-2 w-full">
<CategoriesSidebar
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<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>
{session.data?.user && (
<Button asChild variant="elevated">
<Link href="/library">
<BookmarkCheckIcon />
Library
</Link>
</Button>
)}
</div>
</div>
);
};
export default SearchInput;
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { BookmarkCheckIcon, ListFilterIcon, SearchIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useTRPC } from '@/trpc/client';
import CategoriesSidebar from './categories-sidebar';
interface SearchInputProps {
disabled?: boolean;
}
const SearchInput = ({ disabled }: SearchInputProps) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const trpc = useTRPC();
const session = useQuery(trpc.auth.session.queryOptions());
return (
<div>
<div className="flex items-center gap-2 w-full">
<CategoriesSidebar
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
<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>
{session.data?.user && (
<Button asChild variant="elevated">
<Link href="/library">
<BookmarkCheckIcon />
Library
</Link>
</Button>
)}
</div>
</div>
);
};
export default SearchInput;
@@ -1,60 +1,60 @@
import Link from 'next/link';
import { Category } from '@/payload-types';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface SubCategoryMenuProps {
category: CategoriesGetManyOutput[1];
isOpen: boolean;
position: {
top: number;
left: number;
};
}
const SubCategoryMenu = ({
category,
isOpen,
position,
}: SubCategoryMenuProps) => {
if (
!isOpen ||
!category.subcategories ||
category.subcategories.length === 0
) {
return null;
}
const backgroundColor = category.color || '#F5F5F5';
return (
<div
className="fixed z-100"
style={{
top: position?.top,
left: position?.left,
}}
>
{/* Invisible bridge to maintain hover */}
<div className="h-3 w-60" />
<div
className="w-60 text-black rounded-md overflow-hidden border shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[2px] -translate-y-[2px]"
style={{ backgroundColor }}
>
<div>
{category.subcategories.map((subcategory: Category) => (
<Link
key={subcategory.id}
href={`/${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}
</Link>
))}
</div>
</div>
</div>
);
};
export default SubCategoryMenu;
import Link from 'next/link';
import { Category } from '@/payload-types';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface SubCategoryMenuProps {
category: CategoriesGetManyOutput[1];
isOpen: boolean;
position: {
top: number;
left: number;
};
}
const SubCategoryMenu = ({
category,
isOpen,
position,
}: SubCategoryMenuProps) => {
if (
!isOpen ||
!category.subcategories ||
category.subcategories.length === 0
) {
return null;
}
const backgroundColor = category.color || '#F5F5F5';
return (
<div
className="fixed z-100"
style={{
top: position?.top,
left: position?.left,
}}
>
{/* Invisible bridge to maintain hover */}
<div className="h-3 w-60" />
<div
className="w-60 text-black rounded-md overflow-hidden border shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-[2px] -translate-y-[2px]"
style={{ backgroundColor }}
>
<div>
{category.subcategories.map((subcategory: Category) => (
<Link
key={subcategory.id}
href={`/${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}
</Link>
))}
</div>
</div>
</div>
);
};
export default SubCategoryMenu;
@@ -1,36 +1,36 @@
import { RefObject } from "react";
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
const getDropdownPosition = () => {
if (!ref.current) return { top: 0, left: 0 };
const rect = ref.current.getBoundingClientRect();
const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px)
// Calculate the initial position
let left = rect.left + window.scrollX;
const top = rect.bottom + window.scrollY;
// Check if dropdown would go off the right edge of the viewport
if (left + dropdownWidth > window.innerWidth) {
left = rect.right + window.scrollX - dropdownWidth;
// If still off-screen, align to the left edge of the viewport with some padding
if (left < 0) {
left = window.innerWidth - dropdownWidth - 16; // 16px padding
}
if (left < 0) {
left = 16
}
return { top, left };
}
return { top, left };
}
return { getDropdownPosition }
}
import { RefObject } from "react";
const useDropdownPosition = (ref: RefObject<HTMLDivElement | null> | RefObject<HTMLDivElement>) => {
const getDropdownPosition = () => {
if (!ref.current) return { top: 0, left: 0 };
const rect = ref.current.getBoundingClientRect();
const dropdownWidth = 240; // Width of dropdown (w-60 = 15rem = 240px)
// Calculate the initial position
let left = rect.left + window.scrollX;
const top = rect.bottom + window.scrollY;
// Check if dropdown would go off the right edge of the viewport
if (left + dropdownWidth > window.innerWidth) {
left = rect.right + window.scrollX - dropdownWidth;
// If still off-screen, align to the left edge of the viewport with some padding
if (left < 0) {
left = window.innerWidth - dropdownWidth - 16; // 16px padding
}
if (left < 0) {
left = 16
}
return { top, left };
}
return { top, left };
}
return { getDropdownPosition }
}
export default useDropdownPosition;