feat(storefront): franquicias, sagas y personajes (Disney > Toy Story > Rex)

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
This commit is contained in:
devopsandClaude Opus 5.5 committed 2026-09-27 07:06:32 +00:00
1 parent 271ea30f44
commit 472c12ec0a
4 files changed
+290 -33

No files matched your search

@@ -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<TaxonomyFilter>(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<string, number> = { 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<string, number> = { 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<string, number> = { 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<string, number> = { 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<string, number> = { 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({
</nav>
)}
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
Catálogo completo
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
</span>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
<p className="mt-3 text-slate-500">
Filtra por categoría, marca o edad sin recargar la página.
</p>
@@ -381,6 +435,33 @@ export function CatalogClient({
onChange={setCategory}
counts={categoryCounts}
/>
{franchises.length > 1 && (
<Filter
title="Franquicia"
values={franchises}
selected={taxonomy.franquicia || "Todas"}
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
counts={franchiseCounts}
/>
)}
{taxonomy.saga && (
<div>
<p className="mb-3 text-sm font-black">Saga</p>
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
{taxonomy.saga}
<X className="size-3" aria-label="Quitar saga" />
</button>
</div>
)}
{characters.length > 1 && (
<Filter
title="Personaje"
values={characters}
selected={taxonomy.personaje || "Todos"}
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
counts={characterCounts}
/>
)}
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />