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

+68
View File
@@ -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)}`;
}