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:
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} />
|
||||
|
||||
|
||||
Reference in new issue
Block a user