From 472c12ec0a5fedeefc71175a1afc7cb856068d07 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sun, 27 Sep 2026 06:19:14 +0000 Subject: [PATCH] feat(storefront): franquicias, sagas y personajes (Disney > Toy Story > Rex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taxonomía desde tags de Medusa con prefijo (franquicia:/saga:/personaje:), muchos-a-muchos (ej. "Minnie y Mickey" tiene dos personajes). Pixar va dentro de Disney. - lib/taxonomy.ts: helpers + árbol franquicia > saga > personaje. - Navbar: "Personajes" pasa a mega panel por franquicia (con totales, sagas y personajes); el menú lateral mobile muestra franquicias y sagas. Fallback a colecciones si el provider no trae tags. - Catálogo: filtros "Franquicia" y "Personaje" (acotado a la franquicia), chip removible de "Saga", ?franquicia/?saga/?personaje en la URL, título según la taxonomía de la URL, búsqueda normalizada ("spiderman" encuentra "Spider-Man"). Conteos client-side (no son facetas de Meilisearch). - allowedCategories memoizado (elimina warnings exhaustive-deps). Revisado por frontend-critic (contraste de personajes /80, H1 solo desde la URL, Escape devuelve el foco al trigger) + Playwright: check-taxonomy 17/17 + extras, shot-navbar sin regresiones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP --- workloads/ecommerce/app/catalog/page.tsx | 8 +- .../components/catalog/catalog-client.tsx | 121 ++++++++++++++--- .../ecommerce/components/layout/navbar.tsx | 126 ++++++++++++++++-- workloads/ecommerce/lib/taxonomy.ts | 68 ++++++++++ 4 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 workloads/ecommerce/lib/taxonomy.ts diff --git a/workloads/ecommerce/app/catalog/page.tsx b/workloads/ecommerce/app/catalog/page.tsx index d548dd4..dac4e0c 100644 --- a/workloads/ecommerce/app/catalog/page.tsx +++ b/workloads/ecommerce/app/catalog/page.tsx @@ -22,6 +22,11 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) { const category = single(params.category) || "Todos"; const collection = single(params.collection); const query = single(params.q); + const taxonomy = { + franquicia: single(params.franquicia), + saga: single(params.saga), + personaje: single(params.personaje), + }; // Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y // morrales"), pero ningun producto tiene esa categoria asignada @@ -39,13 +44,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) { return ( ); } diff --git a/workloads/ecommerce/components/catalog/catalog-client.tsx b/workloads/ecommerce/components/catalog/catalog-client.tsx index 847193b..84ba18f 100644 --- a/workloads/ecommerce/components/catalog/catalog-client.tsx +++ b/workloads/ecommerce/components/catalog/catalog-client.tsx @@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { Category, FacetDistribution, Product } from "@/lib/headless/types"; +import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy"; // Cada cuantas tarjetas de producto se intercala un tile promocional en // la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles @@ -23,12 +24,19 @@ type CatalogClientProps = { initialCategory?: string; initialCollection?: string; initialQuery?: string; + initialTaxonomy?: TaxonomyFilter; }; +// Sin guiones ni espacios: "spiderman" encuentra "Spider-Man". +function normalizeSearch(value: string): string { + return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, ""); +} + function matchesQuery(product: Product, query: string): boolean { if (!query) return true; - const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase(); - return search.includes(query.toLowerCase()); + const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")]; + const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" ")); + return search.includes(normalizeSearch(query)); } function matchesCategory(product: Product, allowedCategories: string[]): boolean { return allowedCategories.includes(product.category); @@ -69,8 +77,18 @@ export function CatalogClient({ initialCategory = "Todos", initialCollection = "", initialQuery = "", + initialTaxonomy = {}, }: CatalogClientProps) { const [query, setQuery] = useState(initialQuery); + // Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side + // sobre los tags -- Meilisearch no tiene estos campos como facetas. + const [taxonomy, setTaxonomy] = useState(initialTaxonomy); + const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje); + // El titulo solo refleja la franquicia/saga/personaje que viene en la URL + // (menu "Personajes"), igual que la barra de navegacion: si reaccionara + // al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con + // "Todo" marcado activo arriba (reportado por frontend-critic). + const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || ""; const [category, setCategory] = useState(initialCategory); const [collection, setCollection] = useState(initialCollection); const [brand, setBrand] = useState("Todas"); @@ -96,13 +114,30 @@ export function CatalogClient({ setAge("Todas"); setSort("featured"); setMaxPrice(maxCatalogPrice); - }, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]); + setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje }); + }, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]); + + // Valores de franquicia/personaje presentes en el catalogo. Los + // personajes se acotan a la franquicia elegida (si hay una), para no + // ofrecer "Spider-Man" mientras se mira Disney. + const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]); + const characters = useMemo( + () => [ + "Todos", + ...new Set( + products + .filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga })) + .flatMap((product) => taxonomyValues(product, "personaje")), + ), + ], + [products, taxonomy.franquicia, taxonomy.saga], + ); // 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 allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]); const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]); // No promociona la misma categoria raiz que el usuario ya esta viendo. const promoRoots = useMemo(() => { @@ -120,7 +155,8 @@ export function CatalogClient({ matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && - matchesPrice(product, maxPrice), + matchesPrice(product, maxPrice) && + matchesTaxonomy(product, taxonomy), ) .sort((a, b) => sort === "name" @@ -129,7 +165,7 @@ export function CatalogClient({ ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured), ), - [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort], + [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy], ); // Intercala un tile promocional cada PROMO_TILE_INTERVAL productos, @@ -161,7 +197,7 @@ export function CatalogClient({ // la busqueda en vivo todavia no respondio. const fallbackCategoryCounts = useMemo(() => { const base = products.filter( - (product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice), + (product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, taxonomy), ); const counts: Record = { Todos: base.length }; for (const name of categories) { @@ -170,7 +206,7 @@ export function CatalogClient({ counts[name] = base.filter((product) => matchesCategory(product, allowed)).length; } return counts; - }, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]); + }, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]); const fallbackBrandCounts = useMemo(() => { const base = products.filter( @@ -179,7 +215,8 @@ export function CatalogClient({ (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesAge(product, age) && - matchesPrice(product, maxPrice), + matchesPrice(product, maxPrice) && + matchesTaxonomy(product, taxonomy), ); const counts: Record = { Todas: base.length }; for (const value of brands) { @@ -187,7 +224,7 @@ export function CatalogClient({ counts[value] = base.filter((product) => product.brand === value).length; } return counts; - }, [age, allowedCategories, brands, category, collection, maxPrice, products, query]); + }, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]); const fallbackAgeCounts = useMemo(() => { const base = products.filter( @@ -196,7 +233,8 @@ export function CatalogClient({ (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && - matchesPrice(product, maxPrice), + matchesPrice(product, maxPrice) && + matchesTaxonomy(product, taxonomy), ); const counts: Record = { Todas: base.length }; for (const value of ages) { @@ -204,7 +242,7 @@ export function CatalogClient({ counts[value] = base.filter((product) => product.ageRange === value).length; } return counts; - }, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]); + }, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]); // "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe // como valor indexado en Meilisearch (ningun producto lo tiene como @@ -296,18 +334,33 @@ export function CatalogClient({ }, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]); const categoryCounts = useMemo( - () => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), - [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal], + () => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), + [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive], ); const brandCounts = useMemo( - () => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), - [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal], + () => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), + [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive], ); const ageCounts = useMemo( - () => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), - [fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal], + () => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), + [fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive], ); + // Conteos de franquicia/personaje: siempre client-side (no son facetas + // de Meilisearch), excluyendo su propia dimension como el resto. + const franchiseCounts = useMemo(() => { + const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje })); + const counts: Record = { Todas: base.length }; + for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length; + return counts; + }, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]); + const characterCounts = useMemo(() => { + const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga })); + const counts: Record = { Todos: base.length }; + for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length; + return counts; + }, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]); + const reset = () => { setQuery(""); setCategory("Todos"); @@ -316,6 +369,7 @@ export function CatalogClient({ setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); + setTaxonomy({}); }; return ( @@ -343,9 +397,9 @@ export function CatalogClient({ )} - Catálogo completo + {urlTaxonomyTitle ? "Personajes" : "Catálogo completo"} -

Todos los tesoros

+

{urlTaxonomyTitle || "Todos los tesoros"}

Filtra por categoría, marca o edad sin recargar la página.

@@ -381,6 +435,33 @@ export function CatalogClient({ onChange={setCategory} counts={categoryCounts} /> + {franchises.length > 1 && ( + setTaxonomy({ franquicia: value === "Todas" ? undefined : value })} + counts={franchiseCounts} + /> + )} + {taxonomy.saga && ( +
+

Saga

+ +
+ )} + {characters.length > 1 && ( + setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })} + counts={characterCounts} + /> + )} diff --git a/workloads/ecommerce/components/layout/navbar.tsx b/workloads/ecommerce/components/layout/navbar.tsx index c77f484..9a67748 100644 --- a/workloads/ecommerce/components/layout/navbar.tsx +++ b/workloads/ecommerce/components/layout/navbar.tsx @@ -10,6 +10,7 @@ import { PredictiveSearch } from "@/components/search/predictive-search"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import type { Category, Product } from "@/lib/headless/types"; +import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy"; import { useCartStore } from "@/store/cart-store"; import { useCustomerStore } from "@/store/customer-store"; import { useWishlistStore } from "@/store/wishlist-store"; @@ -43,6 +44,10 @@ export function Navbar({ products, categories }: { products: Product[]; categori 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. + // Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el + // provider no trae tags (mock/shopify), "Personajes" cae a la lista plana + // de colecciones de siempre. + const franchises = useMemo(() => buildFranchiseTree(products), [products]); const collections = useMemo( () => [...new Set(products.map((product) => product.collection).filter(Boolean))], [products], @@ -131,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori - setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}> - setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} /> + setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}> + setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} /> @@ -148,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori setMenuOpen(false)} /> - {collections.length > 0 && ( + {franchises.length > 0 && ( + <> +

Personajes

+
+ {franchises.map((franchise) => ( +
+ setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral"> + {franchise.name} ({franchise.count}) + +
+ {franchise.sagas.map((saga) => ( + setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral"> + {saga.name} + + ))} +
+
+ ))} +
+ + )} + {franchises.length === 0 && collections.length > 0 && ( <>

Colecciones

@@ -174,12 +200,16 @@ function useCategoryNavigationState() { const searchParams = useSearchParams(); const currentCategory = searchParams.get("category") ?? ""; const currentCollection = searchParams.get("collection") ?? ""; + const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" }; + const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje); const isCatalog = pathname === "/catalog"; return { - isAllActive: isCatalog && !currentCategory && !currentCollection, + isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy, isActive: (name: string) => isCatalog && currentCategory === name, isCollectionActive: (name: string) => isCatalog && currentCollection === name, + isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name, + anyTaxonomyActive: isCatalog && anyTaxonomy, }; } @@ -189,6 +219,8 @@ const INACTIVE_NAVIGATION: NavigationState = { isAllActive: false, isActive: () => false, isCollectionActive: () => false, + isTaxonomyActive: () => false, + anyTaxonomyActive: false, }; function pillClass(active: boolean, dense = false): string { @@ -207,7 +239,7 @@ function mobileLinkClass(active: boolean): string { }`; } -type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void }; +type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void }; function DesktopCategoryNavigation(props: NavBarProps) { return ; @@ -223,9 +255,9 @@ function DesktopCategoryNavigationFallback(props: NavBarProps) { // 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de // "Departamentos". "Personajes" (colecciones) va a la derecha desde lg. // La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra. -function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) { - const { isActive, isAllActive, isCollectionActive } = state; - const anyCollectionActive = collections.some(isCollectionActive); +function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) { + const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state; + const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive; return (
- {collections.length > 0 && ( + {(franchises.length > 0 || collections.length > 0) && ( }> - {(close) => ( + {(close) => franchises.length > 0 ? ( + + ) : (
{collections.map((collection) => ( boolean; + close: () => void; +}) { + const link = (kind: TaxonomyKind, name: string, base: string) => ( + + {name} + + ); + return ( +
+ {franchises.map((franchise) => ( +
+

+ {link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")} + {franchise.count} +

+
    + {franchise.sagas.map((saga) => ( +
  • + {link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")} + {saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? ( +
    + {saga.characters.map((character) => ( + {link("personaje", character.name, "text-xs text-white/80 hover:text-white")} + ))} +
    + ) : null} +
  • + ))} +
+
+ ))} +
+ ); +} + const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white"; // Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu @@ -372,6 +458,7 @@ function NavDropdown({ const [open, setOpen] = useState(false); const containerRef = useRef(null); const hoverOpenedAt = useRef(0); + const triggerRef = useRef(null); const closeTimer = useRef | null>(null); const cancelClose = () => { @@ -391,7 +478,13 @@ function NavDropdown({ if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false); } function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") { + setOpen(false); + // Devolver el foco al boton que abrio el menu: si no, queda en + // y quien navega con teclado pierde su posicion (reportado + // por frontend-critic). + triggerRef.current?.focus(); + } } document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); @@ -406,6 +499,7 @@ function NavDropdown({ return (
{ if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>