mirror of
https://github.com/Nezumi-2711/multitenant-ecommerce.git
synced 2026-09-22 05:31:56 +00:00
feat: add category pages
This commit is contained in:
@@ -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;
|
||||
@@ -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,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
|
||||
+65
-65
@@ -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>
|
||||
);
|
||||
};
|
||||
+113
-113
@@ -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;
|
||||
+132
-127
@@ -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;
|
||||
+86
-86
@@ -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;
|
||||
+63
-63
@@ -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;
|
||||
+60
-60
@@ -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;
|
||||
+35
-35
@@ -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;
|
||||
Reference in New Issue
Block a user