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
@@ -22,6 +22,11 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
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 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
|
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
|
||||||
// morrales"), pero ningun producto tiene esa categoria asignada
|
// morrales"), pero ningun producto tiene esa categoria asignada
|
||||||
@@ -39,13 +44,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CatalogClient
|
<CatalogClient
|
||||||
key={`${category}:${collection}:${query}`}
|
key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
|
||||||
products={products}
|
products={products}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
initialCategory={category}
|
initialCategory={category}
|
||||||
initialCollection={collection}
|
initialCollection={collection}
|
||||||
initialQuery={query}
|
initialQuery={query}
|
||||||
|
initialTaxonomy={taxonomy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
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
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
||||||
@@ -23,12 +24,19 @@ type CatalogClientProps = {
|
|||||||
initialCategory?: string;
|
initialCategory?: string;
|
||||||
initialCollection?: string;
|
initialCollection?: string;
|
||||||
initialQuery?: 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 {
|
function matchesQuery(product: Product, query: string): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
|
||||||
return search.includes(query.toLowerCase());
|
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 {
|
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
||||||
return allowedCategories.includes(product.category);
|
return allowedCategories.includes(product.category);
|
||||||
@@ -69,8 +77,18 @@ export function CatalogClient({
|
|||||||
initialCategory = "Todos",
|
initialCategory = "Todos",
|
||||||
initialCollection = "",
|
initialCollection = "",
|
||||||
initialQuery = "",
|
initialQuery = "",
|
||||||
|
initialTaxonomy = {},
|
||||||
}: CatalogClientProps) {
|
}: CatalogClientProps) {
|
||||||
const [query, setQuery] = useState(initialQuery);
|
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 [category, setCategory] = useState(initialCategory);
|
||||||
const [collection, setCollection] = useState(initialCollection);
|
const [collection, setCollection] = useState(initialCollection);
|
||||||
const [brand, setBrand] = useState("Todas");
|
const [brand, setBrand] = useState("Todas");
|
||||||
@@ -96,13 +114,30 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
setMaxPrice(maxCatalogPrice);
|
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
|
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
|
||||||
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
||||||
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
||||||
// trata como el nombre de categoria exacto de siempre.
|
// 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]);
|
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
||||||
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
||||||
const promoRoots = useMemo(() => {
|
const promoRoots = useMemo(() => {
|
||||||
@@ -120,7 +155,8 @@ export function CatalogClient({
|
|||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
)
|
)
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
sort === "name"
|
sort === "name"
|
||||||
@@ -129,7 +165,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, 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,
|
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
||||||
@@ -161,7 +197,7 @@ export function CatalogClient({
|
|||||||
// la busqueda en vivo todavia no respondio.
|
// la busqueda en vivo todavia no respondio.
|
||||||
const fallbackCategoryCounts = useMemo(() => {
|
const fallbackCategoryCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
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 };
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
for (const name of categories) {
|
for (const name of categories) {
|
||||||
@@ -170,7 +206,7 @@ export function CatalogClient({
|
|||||||
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackBrandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -179,7 +215,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of brands) {
|
for (const value of brands) {
|
||||||
@@ -187,7 +224,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.brand === value).length;
|
counts[value] = base.filter((product) => product.brand === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackAgeCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -196,7 +233,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of ages) {
|
for (const value of ages) {
|
||||||
@@ -204,7 +242,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.ageRange === value).length;
|
counts[value] = base.filter((product) => product.ageRange === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
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
|
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
||||||
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||||
@@ -296,18 +334,33 @@ export function CatalogClient({
|
|||||||
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||||
|
|
||||||
const categoryCounts = useMemo(
|
const categoryCounts = useMemo(
|
||||||
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
() => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||||
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const brandCounts = useMemo(
|
const brandCounts = useMemo(
|
||||||
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
() => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||||
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const ageCounts = useMemo(
|
const ageCounts = useMemo(
|
||||||
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
() => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||||
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
[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 = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
@@ -316,6 +369,7 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setMaxPrice(maxCatalogPrice);
|
setMaxPrice(maxCatalogPrice);
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
|
setTaxonomy({});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,9 +397,9 @@ export function CatalogClient({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
||||||
Catálogo completo
|
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
|
||||||
</span>
|
</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">
|
<p className="mt-3 text-slate-500">
|
||||||
Filtra por categoría, marca o edad sin recargar la página.
|
Filtra por categoría, marca o edad sin recargar la página.
|
||||||
</p>
|
</p>
|
||||||
@@ -381,6 +435,33 @@ export function CatalogClient({
|
|||||||
onChange={setCategory}
|
onChange={setCategory}
|
||||||
counts={categoryCounts}
|
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="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
||||||
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PredictiveSearch } from "@/components/search/predictive-search";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type { Category, Product } from "@/lib/headless/types";
|
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 { useCartStore } from "@/store/cart-store";
|
||||||
import { useCustomerStore } from "@/store/customer-store";
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
import { useWishlistStore } from "@/store/wishlist-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]);
|
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||||
// Derivado de los productos reales (no de un endpoint de colecciones):
|
// Derivado de los productos reales (no de un endpoint de colecciones):
|
||||||
// solo aparecen colecciones que de verdad tienen productos hoy.
|
// 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(
|
const collections = useMemo(
|
||||||
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
||||||
[products],
|
[products],
|
||||||
@@ -131,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
||||||
<DesktopCategoryNavigation tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
<DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
@@ -148,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{collections.length > 0 && (
|
{franchises.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
|
||||||
|
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
|
||||||
|
</Link>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => 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}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{franchises.length === 0 && collections.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -174,12 +200,16 @@ function useCategoryNavigationState() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentCategory = searchParams.get("category") ?? "";
|
const currentCategory = searchParams.get("category") ?? "";
|
||||||
const currentCollection = searchParams.get("collection") ?? "";
|
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";
|
const isCatalog = pathname === "/catalog";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAllActive: isCatalog && !currentCategory && !currentCollection,
|
isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
|
||||||
isActive: (name: string) => isCatalog && currentCategory === name,
|
isActive: (name: string) => isCatalog && currentCategory === name,
|
||||||
isCollectionActive: (name: string) => isCatalog && currentCollection === 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,
|
isAllActive: false,
|
||||||
isActive: () => false,
|
isActive: () => false,
|
||||||
isCollectionActive: () => false,
|
isCollectionActive: () => false,
|
||||||
|
isTaxonomyActive: () => false,
|
||||||
|
anyTaxonomyActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function pillClass(active: boolean, dense = false): string {
|
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) {
|
function DesktopCategoryNavigation(props: NavBarProps) {
|
||||||
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
||||||
@@ -223,9 +255,9 @@ function DesktopCategoryNavigationFallback(props: NavBarProps) {
|
|||||||
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
|
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
|
||||||
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
||||||
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
||||||
function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||||
const { isActive, isAllActive, isCollectionActive } = state;
|
const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
|
||||||
const anyCollectionActive = collections.some(isCollectionActive);
|
const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-ari-marino text-white">
|
<nav className="bg-ari-marino text-white">
|
||||||
@@ -289,9 +321,11 @@ function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
|
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
|
||||||
{collections.length > 0 && (
|
{(franchises.length > 0 || collections.length > 0) && (
|
||||||
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
|
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
|
||||||
{(close) => (
|
{(close) => franchises.length > 0 ? (
|
||||||
|
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
|
||||||
|
) : (
|
||||||
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
|
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
|
||||||
{collections.map((collection) => (
|
{collections.map((collection) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -347,6 +381,58 @@ function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
|
|||||||
return !open;
|
return !open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
|
||||||
|
// Earth): una columna por franquicia con su total, sus sagas y los
|
||||||
|
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
|
||||||
|
// "Spider-Man" solo lo de Spider-Man.
|
||||||
|
function FranchisePanel({
|
||||||
|
franchises,
|
||||||
|
isTaxonomyActive,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
franchises: FranchiseNode[];
|
||||||
|
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const link = (kind: TaxonomyKind, name: string, base: string) => (
|
||||||
|
<Link
|
||||||
|
href={taxonomyHref(kind, name)}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
|
||||||
|
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
|
||||||
|
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2.5 grid gap-2.5">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<li key={saga.name}>
|
||||||
|
{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) ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
{saga.characters.map((character) => (
|
||||||
|
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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";
|
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
|
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
|
||||||
@@ -372,6 +458,7 @@ function NavDropdown({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hoverOpenedAt = useRef(0);
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelClose = () => {
|
const cancelClose = () => {
|
||||||
@@ -391,7 +478,13 @@ function NavDropdown({
|
|||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
}
|
}
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
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
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -406,6 +499,7 @@ function NavDropdown({
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
@@ -430,6 +524,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hoverOpenedAt = useRef(0);
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelClose = () => {
|
const cancelClose = () => {
|
||||||
@@ -455,7 +550,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
}
|
}
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
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
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -476,6 +577,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// Taxonomia Franquicia > Saga > Personaje (ej. Disney > Toy Story > Rex,
|
||||||
|
// Marvel > Spider-Man > Spider-Man). Vive en los tags de Medusa con
|
||||||
|
// prefijo ("franquicia:Disney", "saga:Toy Story", "personaje:Rex") porque
|
||||||
|
// la coleccion de Medusa es una sola por producto y mezclaba niveles
|
||||||
|
// ("Disney" y "Toy Story" como pares). Los tags son muchos-a-muchos: un
|
||||||
|
// producto puede tener varios personajes ("Minnie y Mickey").
|
||||||
|
export type TaxonomyKind = "franquicia" | "saga" | "personaje";
|
||||||
|
|
||||||
|
export const TAXONOMY_KINDS: TaxonomyKind[] = ["franquicia", "saga", "personaje"];
|
||||||
|
|
||||||
|
export function taxonomyValues(product: Product, kind: TaxonomyKind): string[] {
|
||||||
|
const prefix = `${kind}:`;
|
||||||
|
return product.tags.filter((tag) => tag.startsWith(prefix)).map((tag) => tag.slice(prefix.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaxonomyFilter = Partial<Record<TaxonomyKind, string>>;
|
||||||
|
|
||||||
|
export function matchesTaxonomy(product: Product, filter: TaxonomyFilter): boolean {
|
||||||
|
return TAXONOMY_KINDS.every((kind) => {
|
||||||
|
const value = filter[kind];
|
||||||
|
return !value || taxonomyValues(product, kind).includes(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SagaNode = { name: string; count: number; characters: { name: string; count: number }[] };
|
||||||
|
export type FranchiseNode = { name: string; count: number; sagas: SagaNode[] };
|
||||||
|
|
||||||
|
function byCountThenName<T extends { name: string; count: number }>(a: T, b: T): number {
|
||||||
|
return b.count - a.count || a.name.localeCompare(b.name, "es");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFranchiseTree(products: Product[]): FranchiseNode[] {
|
||||||
|
const franchises = new Map<string, { count: number; sagas: Map<string, { count: number; characters: Map<string, number> }> }>();
|
||||||
|
for (const product of products) {
|
||||||
|
const sagas = taxonomyValues(product, "saga");
|
||||||
|
const characters = taxonomyValues(product, "personaje");
|
||||||
|
for (const franchise of taxonomyValues(product, "franquicia")) {
|
||||||
|
const node = franchises.get(franchise) ?? { count: 0, sagas: new Map() };
|
||||||
|
node.count += 1;
|
||||||
|
for (const saga of sagas) {
|
||||||
|
const sagaNode = node.sagas.get(saga) ?? { count: 0, characters: new Map() };
|
||||||
|
sagaNode.count += 1;
|
||||||
|
for (const character of characters) sagaNode.characters.set(character, (sagaNode.characters.get(character) ?? 0) + 1);
|
||||||
|
node.sagas.set(saga, sagaNode);
|
||||||
|
}
|
||||||
|
franchises.set(franchise, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...franchises.entries()]
|
||||||
|
.map(([name, node]) => ({
|
||||||
|
name,
|
||||||
|
count: node.count,
|
||||||
|
sagas: [...node.sagas.entries()]
|
||||||
|
.map(([sagaName, sagaNode]) => ({
|
||||||
|
name: sagaName,
|
||||||
|
count: sagaNode.count,
|
||||||
|
characters: [...sagaNode.characters.entries()].map(([characterName, count]) => ({ name: characterName, count })).sort(byCountThenName),
|
||||||
|
}))
|
||||||
|
.sort(byCountThenName),
|
||||||
|
}))
|
||||||
|
.sort(byCountThenName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taxonomyHref(kind: TaxonomyKind, value: string): string {
|
||||||
|
return `/catalog?${kind}=${encodeURIComponent(value)}`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user