@arroyavecommerce/theme-kinetic 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/docs/design/README.md +22 -0
  2. package/docs/design/kinetic-street-concept.html +558 -0
  3. package/package.json +45 -0
  4. package/src/blocks/_shared.tsx +174 -0
  5. package/src/blocks/carrusel.tsx +109 -0
  6. package/src/blocks/category-tiles.test.tsx +59 -0
  7. package/src/blocks/category-tiles.tsx +70 -0
  8. package/src/blocks/editorial-banner.test.tsx +96 -0
  9. package/src/blocks/editorial-banner.tsx +205 -0
  10. package/src/blocks/faq.test.tsx +68 -0
  11. package/src/blocks/faq.tsx +122 -0
  12. package/src/blocks/featured-collection.test.tsx +80 -0
  13. package/src/blocks/featured-collection.tsx +97 -0
  14. package/src/blocks/features.test.tsx +71 -0
  15. package/src/blocks/features.tsx +159 -0
  16. package/src/blocks/hero.test.tsx +75 -0
  17. package/src/blocks/hero.tsx +290 -0
  18. package/src/blocks/index.tsx +35 -0
  19. package/src/blocks/lookbook.test.tsx +80 -0
  20. package/src/blocks/lookbook.tsx +117 -0
  21. package/src/blocks/marquee.test.tsx +54 -0
  22. package/src/blocks/marquee.tsx +114 -0
  23. package/src/blocks/newsletter.test.tsx +60 -0
  24. package/src/blocks/newsletter.tsx +105 -0
  25. package/src/blocks/product-grid.test.tsx +160 -0
  26. package/src/blocks/product-grid.tsx +76 -0
  27. package/src/blocks/promo-countdown.tsx +75 -0
  28. package/src/blocks/promo.test.tsx +71 -0
  29. package/src/blocks/promo.tsx +120 -0
  30. package/src/blocks/rich-text.test.tsx +67 -0
  31. package/src/blocks/rich-text.tsx +75 -0
  32. package/src/blocks/spacer.test.tsx +54 -0
  33. package/src/blocks/spacer.tsx +56 -0
  34. package/src/blocks/testimonials.test.tsx +74 -0
  35. package/src/blocks/testimonials.tsx +104 -0
  36. package/src/blocks/video.test.tsx +159 -0
  37. package/src/blocks/video.tsx +226 -0
  38. package/src/catalog/buy-box.test.tsx +225 -0
  39. package/src/catalog/buy-box.tsx +353 -0
  40. package/src/catalog/config.test.ts +115 -0
  41. package/src/catalog/config.ts +167 -0
  42. package/src/catalog/filter-controls.test.tsx +149 -0
  43. package/src/catalog/filter-controls.tsx +187 -0
  44. package/src/catalog/gallery.test.tsx +201 -0
  45. package/src/catalog/gallery.tsx +212 -0
  46. package/src/catalog/lightbox.test.tsx +95 -0
  47. package/src/catalog/lightbox.tsx +184 -0
  48. package/src/catalog/pdp.test.tsx +309 -0
  49. package/src/catalog/pdp.tsx +354 -0
  50. package/src/catalog/plp-arreglos.test.tsx +235 -0
  51. package/src/catalog/plp-inline.tsx +54 -0
  52. package/src/catalog/plp-overlay.tsx +129 -0
  53. package/src/catalog/plp-parts.test.tsx +146 -0
  54. package/src/catalog/plp-parts.tsx +292 -0
  55. package/src/catalog/plp-pills.tsx +44 -0
  56. package/src/catalog/plp-sidebar.tsx +47 -0
  57. package/src/catalog/plp.test.tsx +319 -0
  58. package/src/catalog/plp.tsx +259 -0
  59. package/src/catalog/product-card.test.tsx +221 -0
  60. package/src/catalog/product-card.tsx +411 -0
  61. package/src/catalog/registry.tsx +16 -0
  62. package/src/catalog/related.test.tsx +52 -0
  63. package/src/catalog/related.tsx +40 -0
  64. package/src/catalog/reviews.test.tsx +56 -0
  65. package/src/catalog/reviews.tsx +116 -0
  66. package/src/catalog/skeletons.test.tsx +61 -0
  67. package/src/catalog/skeletons.tsx +103 -0
  68. package/src/chrome/announcement-bar.test.tsx +86 -0
  69. package/src/chrome/announcement-bar.tsx +173 -0
  70. package/src/chrome/drawer-movil.test.tsx +113 -0
  71. package/src/chrome/drawer-movil.tsx +342 -0
  72. package/src/chrome/footer.test.tsx +133 -0
  73. package/src/chrome/footer.tsx +603 -0
  74. package/src/chrome/header.test.tsx +165 -0
  75. package/src/chrome/header.tsx +592 -0
  76. package/src/chrome/index.ts +26 -0
  77. package/src/chrome/mega-menu.test.tsx +161 -0
  78. package/src/chrome/mega-menu.tsx +317 -0
  79. package/src/chrome/mini-cart.test.tsx +111 -0
  80. package/src/chrome/mini-cart.tsx +320 -0
  81. package/src/content/home.ts +19 -0
  82. package/src/content/seed.test.ts +33 -0
  83. package/src/content/seed.ts +28 -0
  84. package/src/context.ts +104 -0
  85. package/src/icons/icons.test.tsx +118 -0
  86. package/src/icons/index.ts +136 -0
  87. package/src/icons/payment-icons.tsx +241 -0
  88. package/src/icons/social-icons.tsx +108 -0
  89. package/src/icons/svg-base.tsx +68 -0
  90. package/src/index.ts +49 -0
  91. package/src/puck-config.tsx +404 -0
  92. package/src/smoke.test.ts +8 -0
  93. package/src/template.test.tsx +88 -0
  94. package/src/test/mock-ctx.test.tsx +77 -0
  95. package/src/test/mock-ctx.tsx +236 -0
  96. package/src/theme-defaults.test.ts +9 -0
  97. package/src/theme.test.ts +82 -0
  98. package/src/theme.ts +195 -0
  99. package/tsconfig.json +4 -0
@@ -0,0 +1,167 @@
1
+ import type { CmsPageConfig, PageConfigSetting } from "@arroyavecommerce/cms-core"
2
+ import { resolvePageConfig } from "@arroyavecommerce/cms-storefront"
3
+ import type { PlpConfig } from "./plp"
4
+ import type { PdpConfig } from "./pdp"
5
+
6
+ /** Devuelve `valor` si pertenece a `opciones`, si no el `fallback` (mismo criterio defensivo que el dispatcher `?? ArregloSidebar` de 4d-ii). */
7
+ function enOEnum<T extends string>(valor: string, opciones: readonly T[], fallback: T): T {
8
+ return (opciones as readonly string[]).includes(valor) ? (valor as T) : fallback
9
+ }
10
+
11
+ /** `"si"` -> true, cualquier otro string -> el `fallback` booleano de esa clave. */
12
+ function enOBooleano(valor: string, fallback: boolean): boolean {
13
+ return valor === "si" ? true : valor === "no" ? false : fallback
14
+ }
15
+
16
+ /** `CmsPageConfig` (strings) -> `PlpConfig` (tipado), con coerción + fallback defensivo por clave. */
17
+ export function resolverPlpConfig(pageConfig: CmsPageConfig | null): PlpConfig {
18
+ const filtros = enOEnum(
19
+ resolvePageConfig(pageConfig, "plp.filtros", "sidebar"),
20
+ ["sidebar", "pills", "overlay", "inline"] as const,
21
+ "sidebar"
22
+ )
23
+ const columnasRaw = resolvePageConfig(pageConfig, "plp.columnas", "3")
24
+ const columnas = columnasRaw === "3" || columnasRaw === "4" ? (Number(columnasRaw) as 3 | 4) : 3
25
+ const card = enOEnum(
26
+ resolvePageConfig(pageConfig, "plp.card", "minimal"),
27
+ ["minimal", "quick-add", "tallas", "quick-view"] as const,
28
+ "minimal"
29
+ )
30
+ const paginacion = enOEnum(
31
+ resolvePageConfig(pageConfig, "plp.paginacion", "numerada"),
32
+ ["numerada", "load-more"] as const,
33
+ "numerada"
34
+ )
35
+ const mostrarContador = enOBooleano(resolvePageConfig(pageConfig, "plp.mostrarContador", "si"), true)
36
+ const vista = enOEnum(
37
+ resolvePageConfig(pageConfig, "plp.vista", "grilla"),
38
+ ["grilla", "lista"] as const,
39
+ "grilla"
40
+ )
41
+
42
+ return { filtros, columnas, card, paginacion, mostrarContador, vista }
43
+ }
44
+
45
+ /** `CmsPageConfig` (strings) -> `PdpConfig` (tipado), con coerción + fallback defensivo por clave. */
46
+ export function resolverPdpConfig(pageConfig: CmsPageConfig | null): PdpConfig {
47
+ const galeria = enOEnum(
48
+ resolvePageConfig(pageConfig, "pdp.galeria", "grid"),
49
+ ["grid", "carrusel"] as const,
50
+ "grid"
51
+ )
52
+ const stickyBar = enOBooleano(resolvePageConfig(pageConfig, "pdp.stickyBar", "si"), true)
53
+ const compraRapida = enOBooleano(resolvePageConfig(pageConfig, "pdp.compraRapida", "no"), false)
54
+ const mostrarFavoritos = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarFavoritos", "si"), true)
55
+ const mostrarReseñas = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarReseñas", "no"), false)
56
+ const mostrarRelacionados = enOBooleano(resolvePageConfig(pageConfig, "pdp.mostrarRelacionados", "si"), true)
57
+
58
+ return { galeria, stickyBar, compraRapida, mostrarFavoritos, mostrarReseñas, mostrarRelacionados }
59
+ }
60
+
61
+ /** Descriptor de settings del PLP (spec §8) para la UI de config de página. */
62
+ export const plpSettings: PageConfigSetting[] = [
63
+ {
64
+ key: "plp.filtros",
65
+ label: "Filtros",
66
+ opciones: [
67
+ { value: "sidebar", label: "Sidebar" },
68
+ { value: "pills", label: "Pills" },
69
+ { value: "overlay", label: "Overlay" },
70
+ { value: "inline", label: "Inline" },
71
+ ],
72
+ },
73
+ {
74
+ key: "plp.columnas",
75
+ label: "Columnas",
76
+ opciones: [
77
+ { value: "3", label: "3 columnas" },
78
+ { value: "4", label: "4 columnas" },
79
+ ],
80
+ },
81
+ {
82
+ key: "plp.card",
83
+ label: "Tarjeta de producto",
84
+ opciones: [
85
+ { value: "minimal", label: "Minimal" },
86
+ { value: "quick-add", label: "Quick add" },
87
+ { value: "tallas", label: "Tallas" },
88
+ { value: "quick-view", label: "Quick view" },
89
+ ],
90
+ },
91
+ {
92
+ key: "plp.paginacion",
93
+ label: "Paginación",
94
+ opciones: [
95
+ { value: "numerada", label: "Numerada" },
96
+ { value: "load-more", label: "Cargar más" },
97
+ ],
98
+ },
99
+ {
100
+ key: "plp.mostrarContador",
101
+ label: "Mostrar contador de resultados",
102
+ opciones: [
103
+ { value: "si", label: "Sí" },
104
+ { value: "no", label: "No" },
105
+ ],
106
+ },
107
+ {
108
+ key: "plp.vista",
109
+ label: "Vista del listado",
110
+ opciones: [
111
+ { value: "grilla", label: "Grilla" },
112
+ { value: "lista", label: "Lista" },
113
+ ],
114
+ },
115
+ ]
116
+
117
+ /** Descriptor de settings del PDP (spec §8) para la UI de config de página. */
118
+ export const pdpSettings: PageConfigSetting[] = [
119
+ {
120
+ key: "pdp.galeria",
121
+ label: "Galería",
122
+ opciones: [
123
+ { value: "grid", label: "Grid" },
124
+ { value: "carrusel", label: "Carrusel" },
125
+ ],
126
+ },
127
+ {
128
+ key: "pdp.stickyBar",
129
+ label: "Barra sticky",
130
+ opciones: [
131
+ { value: "si", label: "Sí" },
132
+ { value: "no", label: "No" },
133
+ ],
134
+ },
135
+ {
136
+ key: "pdp.compraRapida",
137
+ label: "Compra rápida",
138
+ opciones: [
139
+ { value: "si", label: "Sí" },
140
+ { value: "no", label: "No" },
141
+ ],
142
+ },
143
+ {
144
+ key: "pdp.mostrarFavoritos",
145
+ label: "Mostrar favoritos",
146
+ opciones: [
147
+ { value: "si", label: "Sí" },
148
+ { value: "no", label: "No" },
149
+ ],
150
+ },
151
+ {
152
+ key: "pdp.mostrarReseñas",
153
+ label: "Mostrar reseñas",
154
+ opciones: [
155
+ { value: "si", label: "Sí" },
156
+ { value: "no", label: "No" },
157
+ ],
158
+ },
159
+ {
160
+ key: "pdp.mostrarRelacionados",
161
+ label: "Mostrar relacionados",
162
+ opciones: [
163
+ { value: "si", label: "Sí" },
164
+ { value: "no", label: "No" },
165
+ ],
166
+ },
167
+ ]
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { fileURLToPath } from "node:url"
3
+ import { describe, expect, it } from "vitest"
4
+ import { renderToStaticMarkup } from "react-dom/server"
5
+ import type { Facet } from "@arroyavecommerce/cms-core"
6
+ import { ControlDeFiltro } from "./filter-controls"
7
+ import { mockCtx } from "../test/mock-ctx"
8
+
9
+ const codigoFuente = readFileSync(fileURLToPath(new URL("./filter-controls.tsx", import.meta.url)), "utf-8")
10
+
11
+ const urlFiltro = (facetId: string, valor: string) => `?${facetId}=${valor}`
12
+
13
+ describe("ControlDeFiltro", () => {
14
+ it("checkbox: renderiza una opcion por opciones con etiqueta+conteo, como Enlace a urlFiltro; marca la seleccionada", () => {
15
+ const facet: Facet = {
16
+ id: "categoria",
17
+ label: "Categoría",
18
+ control: "checkbox",
19
+ source: { type: "tag", tagIds: ["t1", "t2"] },
20
+ order: 1,
21
+ }
22
+ const html = renderToStaticMarkup(
23
+ <ControlDeFiltro
24
+ facet={facet}
25
+ seleccionados={["camisetas"]}
26
+ opciones={[
27
+ { valor: "camisetas", etiqueta: "Camisetas", conteo: 128 },
28
+ { valor: "hoodies", etiqueta: "Hoodies", conteo: 42 },
29
+ ]}
30
+ urlFiltro={urlFiltro}
31
+ ctx={mockCtx()}
32
+ />
33
+ )
34
+
35
+ expect(html).toContain("Camisetas (128)")
36
+ expect(html).toContain("Hoodies (42)")
37
+ expect(html).toContain('href="?categoria=camisetas"')
38
+ expect(html).toContain('href="?categoria=hoodies"')
39
+ // aria-current debe estar en el <a> real (mismo tag que el href), no en el <li>/<span> contenedor.
40
+ expect(html).toMatch(/<a href="\?categoria=camisetas"[^>]*aria-current="true"/)
41
+ })
42
+
43
+ it("pill: renderiza chips (Enlace) por opcion; el seleccionado queda resaltado", () => {
44
+ const facet: Facet = {
45
+ id: "talla",
46
+ label: "Talla",
47
+ control: "pill",
48
+ source: { type: "tag", tagIds: ["t1"] },
49
+ order: 1,
50
+ }
51
+ const html = renderToStaticMarkup(
52
+ <ControlDeFiltro
53
+ facet={facet}
54
+ seleccionados={["S"]}
55
+ opciones={[
56
+ { valor: "S", etiqueta: "S" },
57
+ { valor: "M", etiqueta: "M" },
58
+ ]}
59
+ urlFiltro={urlFiltro}
60
+ ctx={mockCtx()}
61
+ />
62
+ )
63
+
64
+ expect(html).toContain('href="?talla=S"')
65
+ expect(html).toContain('href="?talla=M"')
66
+ // aria-current debe estar en el <a> real (mismo tag que el href), no en el <span> contenedor.
67
+ expect(html).toMatch(/<a href="\?talla=S"[^>]*aria-current="true"/)
68
+ })
69
+
70
+ it('dropdown: renderiza un boton "label ▾"', () => {
71
+ const facet: Facet = {
72
+ id: "color",
73
+ label: "Color",
74
+ control: "dropdown",
75
+ source: { type: "tag", tagIds: ["t1"] },
76
+ order: 1,
77
+ }
78
+ const html = renderToStaticMarkup(
79
+ <ControlDeFiltro
80
+ facet={facet}
81
+ seleccionados={[]}
82
+ opciones={[{ valor: "negro", etiqueta: "Negro" }]}
83
+ urlFiltro={urlFiltro}
84
+ ctx={mockCtx()}
85
+ />
86
+ )
87
+
88
+ expect(html).toContain("Color")
89
+ expect(html).toContain("▾")
90
+ })
91
+
92
+ it("range: renderiza dos extremos de precio (min/max) que navegan por urlFiltro", () => {
93
+ const facet: Facet = {
94
+ id: "precio",
95
+ label: "Precio",
96
+ control: "range",
97
+ source: { type: "price" },
98
+ order: 1,
99
+ }
100
+ const html = renderToStaticMarkup(
101
+ <ControlDeFiltro
102
+ facet={facet}
103
+ seleccionados={[]}
104
+ opciones={[
105
+ { valor: "min", etiqueta: "$ 49.900" },
106
+ { valor: "max", etiqueta: "$ 189.900" },
107
+ ]}
108
+ urlFiltro={urlFiltro}
109
+ ctx={mockCtx()}
110
+ />
111
+ )
112
+
113
+ expect(html).toContain("$ 49.900")
114
+ expect(html).toContain("$ 189.900")
115
+ expect(html).toContain('href="?precio=min"')
116
+ expect(html).toContain('href="?precio=max"')
117
+ })
118
+
119
+ it("faceta invalida (control:range + source:tag) devuelve null", () => {
120
+ const facet: Facet = {
121
+ id: "x",
122
+ label: "X",
123
+ control: "range",
124
+ source: { type: "tag", tagIds: ["x"] },
125
+ order: 1,
126
+ }
127
+ const resultado = ControlDeFiltro({ facet, seleccionados: [], opciones: [], urlFiltro, ctx: mockCtx() })
128
+
129
+ expect(resultado).toBeNull()
130
+ })
131
+
132
+ it("faceta invalida (control:pill + source:price) devuelve null", () => {
133
+ const facet: Facet = {
134
+ id: "y",
135
+ label: "Y",
136
+ control: "pill",
137
+ source: { type: "price" },
138
+ order: 1,
139
+ }
140
+ const resultado = ControlDeFiltro({ facet, seleccionados: [], opciones: [], urlFiltro, ctx: mockCtx() })
141
+
142
+ expect(resultado).toBeNull()
143
+ })
144
+
145
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
146
+ expect(codigoFuente).not.toMatch(/<img[\s>]/)
147
+ expect(codigoFuente).not.toMatch(/<a[\s>]/)
148
+ })
149
+ })
@@ -0,0 +1,187 @@
1
+ "use client"
2
+
3
+ import { useState, type ComponentType } from "react"
4
+ import type { Facet } from "@arroyavecommerce/cms-core"
5
+ import { facetControlEsValido } from "@arroyavecommerce/cms-core"
6
+ import type { RenderContext } from "../context"
7
+
8
+ export type FilterControlProps = {
9
+ facet: Facet
10
+ seleccionados: string[]
11
+ opciones: { valor: string; etiqueta: string; conteo?: number }[]
12
+ urlFiltro: (facetId: string, valor: string) => string
13
+ ctx: RenderContext
14
+ }
15
+
16
+ function etiquetaOpcion(opcion: FilterControlProps["opciones"][number]): string {
17
+ return opcion.conteo != null ? `${opcion.etiqueta} (${opcion.conteo})` : opcion.etiqueta
18
+ }
19
+
20
+ /** <li>+Enlace compartido por CheckboxControl y la lista de DropdownControl: aria-current va en el ancla real. */
21
+ function OpcionEnlace({
22
+ opcion,
23
+ activa,
24
+ href,
25
+ Enlace,
26
+ }: {
27
+ opcion: FilterControlProps["opciones"][number]
28
+ activa: boolean
29
+ href: string
30
+ Enlace: RenderContext["componentes"]["Enlace"]
31
+ }) {
32
+ return (
33
+ <li>
34
+ <Enlace
35
+ href={href}
36
+ aria-current={activa ? "true" : undefined}
37
+ className={`inline-block [text-decoration:none] ${
38
+ activa ? "font-semibold [color:var(--cms-texto)]" : "font-normal [color:var(--cms-texto-suave)]"
39
+ }`}
40
+ >
41
+ {etiquetaOpcion(opcion)}
42
+ </Enlace>
43
+ </li>
44
+ )
45
+ }
46
+
47
+ /** Lista vertical de sidebar (mockup PLP l.44-55): "Camisetas (128)" con la activa resaltada. */
48
+ function CheckboxControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
49
+ const { Enlace } = ctx.componentes
50
+ return (
51
+ <div className="flex flex-col gap-2.5">
52
+ <div
53
+ className="text-[11px] font-semibold uppercase tracking-[0.1em] [color:var(--cms-texto)]"
54
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
55
+ >
56
+ {facet.label}
57
+ </div>
58
+ <ul className="m-0 flex list-none flex-col gap-2 p-0 text-[13px]">
59
+ {opciones.map((opcion) => (
60
+ <OpcionEnlace
61
+ key={opcion.valor}
62
+ opcion={opcion}
63
+ activa={seleccionados.includes(opcion.valor)}
64
+ href={urlFiltro(facet.id, opcion.valor)}
65
+ Enlace={Enlace}
66
+ />
67
+ ))}
68
+ </ul>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ /** Chips horizontales (mockup PLP l.113-118): seleccionado resaltado en primario. */
74
+ function PillControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
75
+ const { Enlace } = ctx.componentes
76
+ return (
77
+ <div className="flex flex-wrap gap-2" role="group" aria-label={facet.label}>
78
+ {opciones.map((opcion) => {
79
+ const activa = seleccionados.includes(opcion.valor)
80
+ return (
81
+ <span key={opcion.valor}>
82
+ <Enlace
83
+ href={urlFiltro(facet.id, opcion.valor)}
84
+ aria-current={activa ? "true" : undefined}
85
+ className={`inline-block rounded-full px-4 py-2 text-[12px] font-semibold [text-decoration:none] ${
86
+ activa
87
+ ? "[background:var(--cms-primario)] [color:var(--cms-primario-texto)]"
88
+ : "font-normal [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [color:var(--cms-texto)]"
89
+ }`}
90
+ >
91
+ {etiquetaOpcion(opcion)}
92
+ </Enlace>
93
+ </span>
94
+ )
95
+ })}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ /** Extremos de precio min/max (mockup PLP l.68-75); navegan por urlFiltro. */
101
+ function RangeControl({ facet, opciones, urlFiltro, ctx }: FilterControlProps) {
102
+ const { Enlace } = ctx.componentes
103
+ const [minOpcion, maxOpcion] = opciones
104
+ return (
105
+ <div className="flex flex-col gap-2.5">
106
+ <div
107
+ className="text-[11px] font-semibold uppercase tracking-[0.1em] [color:var(--cms-texto)]"
108
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
109
+ >
110
+ {facet.label}
111
+ </div>
112
+ <div
113
+ className="flex items-center justify-between text-[11px] font-medium [color:var(--cms-texto-suave)]"
114
+ style={{ fontFamily: "var(--cms-fuente-mono)" }}
115
+ >
116
+ {minOpcion ? (
117
+ <Enlace
118
+ href={urlFiltro(facet.id, minOpcion.valor)}
119
+ className="[color:inherit] [text-decoration:none]"
120
+ >
121
+ {minOpcion.etiqueta}
122
+ </Enlace>
123
+ ) : (
124
+ <span />
125
+ )}
126
+ {maxOpcion ? (
127
+ <Enlace
128
+ href={urlFiltro(facet.id, maxOpcion.valor)}
129
+ className="[color:inherit] [text-decoration:none]"
130
+ >
131
+ {maxOpcion.etiqueta}
132
+ </Enlace>
133
+ ) : (
134
+ <span />
135
+ )}
136
+ </div>
137
+ </div>
138
+ )
139
+ }
140
+
141
+ /** Botón "label ▾" (mockup PLP l.114) que abre un panel de opciones vía useState. */
142
+ function DropdownControl({ facet, seleccionados, opciones, urlFiltro, ctx }: FilterControlProps) {
143
+ const [abierto, setAbierto] = useState(false)
144
+ const { Enlace } = ctx.componentes
145
+ return (
146
+ <div className="relative inline-block text-[12px]">
147
+ <button
148
+ type="button"
149
+ onClick={() => setAbierto((valor) => !valor)}
150
+ aria-expanded={abierto}
151
+ className="inline-flex items-center gap-1.5 px-4 py-2 font-medium [background:var(--cms-superficie)] [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [border-radius:var(--cms-radio)] [color:var(--cms-texto)]"
152
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
153
+ >
154
+ {facet.label} ▾
155
+ </button>
156
+ {abierto ? (
157
+ <ul
158
+ className="absolute left-0 top-full z-10 m-0 mt-1.5 flex min-w-[160px] list-none flex-col gap-1 p-2 text-[12.5px] [background:var(--cms-superficie)] [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] [border-radius:var(--cms-radio)]"
159
+ >
160
+ {opciones.map((opcion) => (
161
+ <OpcionEnlace
162
+ key={opcion.valor}
163
+ opcion={opcion}
164
+ activa={seleccionados.includes(opcion.valor)}
165
+ href={urlFiltro(facet.id, opcion.valor)}
166
+ Enlace={Enlace}
167
+ />
168
+ ))}
169
+ </ul>
170
+ ) : null}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export const filterControls: Record<"pill" | "dropdown" | "checkbox" | "range", ComponentType<FilterControlProps>> = {
176
+ pill: PillControl,
177
+ dropdown: DropdownControl,
178
+ checkbox: CheckboxControl,
179
+ range: RangeControl,
180
+ }
181
+
182
+ /** Resuelve el widget por `facet.control`; devuelve `null` si la combinación control×fuente no es válida. */
183
+ export function ControlDeFiltro(props: FilterControlProps) {
184
+ if (!facetControlEsValido(props.facet)) return null
185
+ const Widget = filterControls[props.facet.control]
186
+ return <Widget {...props} />
187
+ }