fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias #32

Merged
devops merged 1 commits from fix/etapa2-subfase2-navegacion-datadriven into main 2026-08-25 02:27:19 +00:00
11 changed files with 330 additions and 137 deletions
Showing only changes of commit 8742ce714c - Show all commits

No files matched your search

+18 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CatalogClient } from "@/components/catalog/catalog-client"; import { CatalogClient } from "@/components/catalog/catalog-client";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Catálogo", title: "Catálogo",
@@ -17,17 +17,31 @@ function single(value: string | string[] | undefined): string {
export default async function CatalogPage({ searchParams }: CatalogPageProps) { export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const params = await searchParams; const params = await searchParams;
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const category = single(params.category) || "Todos"; const category = single(params.category) || "Todos";
const collection = single(params.collection); const collection = single(params.collection);
const query = single(params.q); const query = single(params.q);
const section = single(params.section);
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada
// directamente -- solo sus hijos ("Marvel", "Frozen", etc.). Este mapa
// expande cada raiz-con-hijos al nombre real de sus hijos para que el
// filtro siga funcionando por igualdad exacta de `product.category`.
const categoryGroups: Record<string, string[]> = {};
for (const node of categories) {
if (node.parentId) continue;
const children = categories.filter((child) => child.parentId === node.id);
if (children.length > 0) {
categoryGroups[node.name] = children.map((child) => child.name);
}
}
return ( return (
<CatalogClient <CatalogClient
key={`${category}:${collection}:${query}:${section}`} key={`${category}:${collection}:${query}`}
products={products} products={products}
categoryGroups={categoryGroups}
initialCategory={category} initialCategory={category}
initialCollection={collection} initialCollection={collection}
initialQuery={query} initialQuery={query}
+3 -3
View File
@@ -4,7 +4,7 @@ import { CartDrawer } from "@/components/cart/cart-drawer";
import { Footer } from "@/components/layout/footer"; import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer"; import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"), metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"),
@@ -16,6 +16,6 @@ export const metadata: Metadata = {
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" }; export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
return <html lang="es"><body><Navbar products={products} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>; return <html lang="es"><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
} }
+3 -3
View File
@@ -5,10 +5,10 @@ import { HeroBento } from "@/components/home/hero-bento";
import { TrustBar } from "@/components/home/trust-bar"; import { TrustBar } from "@/components/home/trust-bar";
import { ProductCarousel } from "@/components/product/product-carousel"; import { ProductCarousel } from "@/components/product/product-carousel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getProducts } from "@/lib/headless/client"; import { getCategories, getProducts } from "@/lib/headless/client";
export default async function HomePage() { export default async function HomePage() {
const products = await getProducts(); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8); const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>; return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
} }
@@ -9,6 +9,7 @@ import type { Product } from "@/lib/headless/types";
type CatalogClientProps = { type CatalogClientProps = {
products: Product[]; products: Product[];
categoryGroups?: Record<string, string[]>;
initialCategory?: string; initialCategory?: string;
initialCollection?: string; initialCollection?: string;
initialQuery?: string; initialQuery?: string;
@@ -16,6 +17,7 @@ type CatalogClientProps = {
export function CatalogClient({ export function CatalogClient({
products, products,
categoryGroups = {},
initialCategory = "Todos", initialCategory = "Todos",
initialCollection = "", initialCollection = "",
initialQuery = "", initialQuery = "",
@@ -48,6 +50,12 @@ export function CatalogClient({
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]); }, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category];
const filtered = useMemo( const filtered = useMemo(
() => () =>
products products
@@ -63,7 +71,7 @@ export function CatalogClient({
return ( return (
(!query || search.includes(query.toLowerCase())) && (!query || search.includes(query.toLowerCase())) &&
(category === "Todos" || product.category === category) && (category === "Todos" || allowedCategories.includes(product.category)) &&
(!collection || product.collection === collection) && (!collection || product.collection === collection) &&
(brand === "Todas" || product.brand === brand) && (brand === "Todas" || product.brand === brand) &&
(age === "Todas" || product.ageRange === age) && (age === "Todas" || product.ageRange === age) &&
@@ -77,7 +85,7 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew) ? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured), : Number(b.featured) - Number(a.featured),
), ),
[age, brand, category, collection, maxPrice, products, query, sort], [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
); );
const reset = () => { const reset = () => {
@@ -1,14 +1,66 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import type { Category } from "@/lib/headless/types";
const categories = [ // No hay fotografia real todavia para las 6 categorias raiz de la taxonomia
{ title: "Juguetes", subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", href: "/catalog?category=Figuras", color: "from-yellow-300 to-orange-400" }, // (ver ADR 0005) -- solo se mapean imagen/subtitulo para las que ya tienen
{ title: "Peluches", subtitle: "Compañeros para regalar", image: "/images/categories/peluches.webp", href: "/catalog?category=Peluches", color: "from-pink-400 to-fuchsia-500" }, // un asset en public/images/categories/. Las demas caen al tile de solo
{ title: "Mochilas", subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", href: "/catalog?category=Mochilas", color: "from-cyan-400 to-blue-500" }, // gradiente en vez de mostrar una imagen que no les corresponde.
{ title: "Marvel", subtitle: "Tu héroe favorito", image: "/images/categories/marvel.webp", href: "/catalog?collection=Marvel", color: "from-red-500 to-rose-700" }, const tileContent: Record<string, { subtitle: string; image?: string; color: string }> = {
]; juguetes: { subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", color: "from-yellow-300 to-orange-400" },
"mochilas-y-morrales": { subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", color: "from-cyan-400 to-blue-500" },
"bebes-y-primera-infancia": { subtitle: "Primeros descubrimientos", color: "from-pink-300 to-fuchsia-400" },
educativos: { subtitle: "Aprender jugando", color: "from-emerald-400 to-teal-600" },
"accion-y-aventura": { subtitle: "Para explorar sin límites", color: "from-red-500 to-rose-700" },
variedades: { subtitle: "Un poco de todo", color: "from-violet-400 to-purple-600" },
};
export function CategoryTiles() { const defaultTile = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
return <section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-4"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2></div><Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link></div><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">{categories.map((item) => <Link key={item.title} href={item.href} className={`group relative min-h-80 overflow-hidden rounded-[2rem] bg-gradient-to-br ${item.color} shadow-xl`}><Image src={item.image} alt={item.title} fill sizes="(max-width: 640px) 100vw, 25vw" className="object-cover transition duration-500 group-hover:scale-105" /><div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" /><div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white"><div><h3 className="text-2xl font-black">{item.title}</h3><p className="mt-1 text-sm font-semibold text-white/80">{item.subtitle}</p></div><span className="grid size-11 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45"><ArrowUpRight /></span></div></Link>)}</div></section>;
export function CategoryTiles({ categories }: { categories: Category[] }) {
const roots = categories.filter((category) => !category.parentId);
if (roots.length === 0) return null;
return (
<section className="mx-auto max-w-7xl px-4 py-14">
<div className="mb-8 flex items-end justify-between gap-4">
<div>
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
</div>
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{roots.map((category) => {
const content = tileContent[category.handle] ?? defaultTile;
return (
<Link
key={category.id}
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
})}
</div>
</section>
);
} }
+166 -116
View File
@@ -3,42 +3,28 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react"; import { ChevronDown, Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import { PredictiveSearch } from "@/components/search/predictive-search"; import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Product } from "@/lib/headless/types"; import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
type CategoryLink = { type CategoryNode = Category & { children: Category[] };
label: string;
href: string;
category?: string;
collection?: string;
section?: string;
root?: boolean;
};
const categories: CategoryLink[] = [ function buildCategoryTree(categories: Category[]): CategoryNode[] {
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras", category: "Figuras" }, return categories
{ .filter((category) => !category.parentId)
label: "Educativos", .map((root) => ({
href: "/catalog?category=Figuras&section=educativos", ...root,
category: "Figuras", children: categories.filter((category) => category.parentId === root.id),
section: "educativos", }));
}, }
{ label: "Muñecas y peluches", href: "/catalog?category=Peluches", category: "Peluches" },
{ label: "Acción y aventura", href: "/catalog?collection=Marvel", collection: "Marvel" },
{ label: "Mochilas", href: "/catalog?category=Mochilas", category: "Mochilas" },
{ label: "Variedades", href: "/catalog", root: true },
];
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"]; export function Navbar({ products, categories }: { products: Product[]; categories: Category[] }) {
export function Navbar({ products }: { products: Product[] }) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false);
const items = useCartStore((state) => state.items); const items = useCartStore((state) => state.items);
@@ -50,6 +36,14 @@ export function Navbar({ products }: { products: Product[] }) {
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0; const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0; const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones):
// solo aparecen colecciones que de verdad tienen productos hoy.
const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products],
);
return ( return (
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm"> <div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
@@ -77,8 +71,8 @@ export function Navbar({ products }: { products: Product[] }) {
</div> </div>
</div> </div>
<Suspense fallback={<DesktopCategoryNavigationFallback onOpenMenu={() => setMenuOpen(true)} />}> <Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation onOpenMenu={() => setMenuOpen(true)} /> <DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
</Suspense> </Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -90,14 +84,18 @@ export function Navbar({ products }: { products: Product[] }) {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p> <p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p>
<div className="grid gap-2"> <div className="grid gap-2">
<Suspense fallback={<MobileCategoryLinksFallback onNavigate={() => setMenuOpen(false)} />}> <Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
<MobileCategoryLinks onNavigate={() => setMenuOpen(false)} /> <MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense> </Suspense>
</div> </div>
{collections.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p> <p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)} {collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
</div> </div>
</>
)}
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden"> <div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button> <Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button> <Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
@@ -127,68 +125,33 @@ function useCategoryNavigationState() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? ""; const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const currentSection = searchParams.get("section") ?? "";
const isCatalog = pathname === "/catalog"; const isCatalog = pathname === "/catalog";
const isCategoryActive = (item: CategoryLink) => { return {
if (!isCatalog) return false; isAllActive: isCatalog && !currentCategory,
isActive: (name: string) => isCatalog && currentCategory === name,
if (item.root) {
return !currentCategory && !currentCollection && !currentSection;
}
return (
(item.category ?? "") === currentCategory &&
(item.collection ?? "") === currentCollection &&
(item.section ?? "") === currentSection
);
}; };
return { isCategoryActive };
} }
function DesktopCategoryNavigation({ onOpenMenu }: { onOpenMenu: () => void }) { function pillClass(active: boolean): string {
const { isCategoryActive } = useCategoryNavigationState(); return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
{categories.map((category) => {
const active = isCategoryActive(category);
return (
<Link
key={category.label}
href={category.href}
prefetch={false}
aria-current={active ? "page" : undefined}
className={`shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
active active
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30" ? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white" : "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
}`} }`;
>
{category.label}
</Link>
);
})}
</div>
</nav>
);
} }
function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => void }) { function mobileLinkClass(active: boolean): string {
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return ( return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white"> <nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]"> <div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
@@ -202,14 +165,76 @@ function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => v
<Menu /> <Menu />
</Button> </Button>
{categories.map((category) => ( <Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
{tree.map((root) =>
root.children.length > 0 ? (
<div key={root.id} className="group relative shrink-0">
<Link <Link
key={category.label} href={`/catalog?category=${encodeURIComponent(root.name)}`}
href={category.href}
prefetch={false} prefetch={false}
className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold text-white/90 transition hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white" aria-current={isActive(root.name) ? "page" : undefined}
className={`flex items-center gap-1 ${pillClass(isActive(root.name))}`}
> >
{category.label} {root.name}
<ChevronDown className="size-3.5 opacity-70" />
</Link>
<div className="invisible absolute left-0 top-full z-50 min-w-56 -translate-y-1 rounded-2xl border border-slate-800 bg-slate-950 p-2 opacity-0 shadow-2xl transition group-hover:visible group-hover:translate-y-0 group-hover:opacity-100">
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
className={`block rounded-xl px-3 py-2 text-sm font-bold transition ${
isActive(child.name) ? "bg-violet-600 text-white" : "text-white/85 hover:bg-white/10"
}`}
>
{child.name}
</Link>
))}
</div>
</div>
) : (
<Link
key={root.id}
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
className={pillClass(isActive(root.name))}
>
{root.name}
</Link>
),
)}
</div>
</nav>
);
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return (
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-3 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
<Link href="/catalog" prefetch={false} className={pillClass(false)}>
Todo
</Link>
{tree.map((root) => (
<Link key={root.id} href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} className={pillClass(false)}>
{root.name}
</Link> </Link>
))} ))}
</div> </div>
@@ -217,50 +242,75 @@ function DesktopCategoryNavigationFallback({ onOpenMenu }: { onOpenMenu: () => v
); );
} }
function MobileCategoryLinks({ onNavigate }: { onNavigate: () => void }) { function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
const { isCategoryActive } = useCategoryNavigationState(); const { isActive, isAllActive } = useCategoryNavigationState();
return ( return (
<> <>
{categories.map((category) => {
const active = isCategoryActive(category);
return (
<Link <Link
key={category.label} href="/catalog"
href={category.href}
prefetch={false} prefetch={false}
aria-current={active ? "page" : undefined} aria-current={isAllActive ? "page" : undefined}
onClick={onNavigate} onClick={onNavigate}
className={`rounded-2xl border px-4 py-3 font-extrabold transition ${ className={mobileLinkClass(isAllActive)}
active
? "border-violet-500 bg-violet-50 text-violet-700"
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
}`}
> >
{category.label} Todo el catálogo
</Link> </Link>
);
})}
</>
);
}
function MobileCategoryLinksFallback({ onNavigate }: { onNavigate: () => void }) { {tree.map((root) => (
return ( <div key={root.id} className="contents">
<>
{categories.map((category) => (
<Link <Link
key={category.label} href={`/catalog?category=${encodeURIComponent(root.name)}`}
href={category.href}
prefetch={false} prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={onNavigate} onClick={onNavigate}
className="rounded-2xl border border-slate-200 px-4 py-3 font-extrabold transition hover:border-cyan-400 hover:bg-cyan-50" className={mobileLinkClass(isActive(root.name))}
> >
{category.label} {root.name}
</Link> </Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={onNavigate}
className={`${mobileLinkClass(isActive(child.name))} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))} ))}
</> </>
); );
} }
function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
return (
<>
<Link href="/catalog" prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
onClick={onNavigate}
className={`${mobileLinkClass(false)} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}
@@ -22,3 +22,7 @@ export async function getProductBySlug(slug: string) {
export async function searchProducts(query: string, limit?: number) { export async function searchProducts(query: string, limit?: number) {
return provider().searchProducts(query, limit); return provider().searchProducts(query, limit);
} }
export async function getCategories() {
return provider().getCategories();
}
@@ -1,5 +1,6 @@
import type { import type {
CatalogProvider, CatalogProvider,
Category,
Product, Product,
ProductFilters, ProductFilters,
ProductVariant, ProductVariant,
@@ -66,6 +67,18 @@ type MedusaProductsResponse = {
offset?: number; offset?: number;
}; };
type MedusaProductCategory = {
id: string;
name?: string | null;
handle?: string | null;
parent_category_id?: string | null;
rank?: number | null;
};
type MedusaCategoriesResponse = {
product_categories: MedusaProductCategory[];
};
function backendUrl(): string { function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL; const value = process.env.MEDUSA_BACKEND_URL;
if (!value) { if (!value) {
@@ -339,11 +352,40 @@ async function fetchProducts(
.filter((product) => matchesFilters(product, filters)); .filter((product) => matchesFilters(product, filters));
} }
async function fetchCategories(): Promise<Category[]> {
const params = new URLSearchParams({
limit: "100",
fields: "id,name,handle,rank,parent_category_id",
});
const response = await medusaFetch<MedusaCategoriesResponse>(
"/store/product-categories",
params,
);
// Ordenar por rank preserva el orden de la taxonomia tal como se creo
// (ver ADR 0005) -- los hijos de cada padre quedan en el mismo orden
// relativo porque el arbol se arma filtrando este arreglo ya ordenado.
return response.product_categories
.slice()
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
.map((category) => ({
id: category.id,
name: category.name || "",
handle: category.handle || category.id,
parentId: category.parent_category_id ?? null,
}));
}
export const medusaCatalogProvider: CatalogProvider = { export const medusaCatalogProvider: CatalogProvider = {
async getProducts(filters) { async getProducts(filters) {
return fetchProducts(filters); return fetchProducts(filters);
}, },
async getCategories() {
return fetchCategories();
},
async getProductBySlug(slug) { async getProductBySlug(slug) {
const params = new URLSearchParams({ const params = new URLSearchParams({
handle: slug, handle: slug,
@@ -1,5 +1,5 @@
import catalog from "@/data/products.json"; import catalog from "@/data/products.json";
import type { CatalogProvider, Product, ProductFilters } from "@/lib/headless/types"; import type { CatalogProvider, Category, Product, ProductFilters } from "@/lib/headless/types";
const products = catalog satisfies Product[]; const products = catalog satisfies Product[];
@@ -39,4 +39,16 @@ export const mockCatalogProvider: CatalogProvider = {
async searchProducts(query, limit = 6) { async searchProducts(query, limit = 6) {
return products.filter((product) => matches(product, { query })).slice(0, limit); return products.filter((product) => matches(product, { query })).slice(0, limit);
}, },
async getCategories(): Promise<Category[]> {
// El catalogo mock no tiene jerarquia real (a diferencia de Medusa) --
// se devuelve una lista plana de las categorias que ya usan los
// productos mock, todas sin padre.
const names = [...new Set(products.map((product) => product.category))].filter(Boolean);
return names.map((name) => ({
id: name,
name,
handle: normalize(name).replace(/\s+/g, "-"),
parentId: null,
}));
},
}; };
@@ -42,4 +42,7 @@ export const shopifyCatalogProvider: CatalogProvider = {
async searchProducts(): Promise<Product[]> { async searchProducts(): Promise<Product[]> {
return notImplemented(); return notImplemented();
}, },
async getCategories() {
return notImplemented();
},
}; };
@@ -39,8 +39,16 @@ export interface ProductFilters {
maxPrice?: number; maxPrice?: number;
} }
export interface Category {
id: string;
name: string;
handle: string;
parentId: string | null;
}
export interface CatalogProvider { export interface CatalogProvider {
getProducts(filters?: ProductFilters): Promise<Product[]>; getProducts(filters?: ProductFilters): Promise<Product[]>;
getProductBySlug(slug: string): Promise<Product | null>; getProductBySlug(slug: string): Promise<Product | null>;
searchProducts(query: string, limit?: number): Promise<Product[]>; searchProducts(query: string, limit?: number): Promise<Product[]>;
getCategories(): Promise<Category[]>;
} }