@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,319 @@
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 { FrameProductos, BarraFiltrosMobil, BottomSheetFiltros, PlpLayout, type PlpConfig } from "./plp"
6
+ import { mockCtx, fixtureListado } from "../test/mock-ctx"
7
+
8
+ const codigoFuente = readFileSync(fileURLToPath(new URL("./plp.tsx", import.meta.url)), "utf-8")
9
+
10
+ /**
11
+ * Regresion 4d-ii: `FrameProductos` (la grilla) estaba embebido DENTRO de
12
+ * cada arreglo de filtros, y los 4 arreglos envolvian TODO su contenido
13
+ * (chrome + grilla) en `hidden md:*` — en mobile la grilla quedaba
14
+ * `display:none`. `renderToStaticMarkup` no evalua CSS, asi que esto no lo
15
+ * detectaban los tests SSR existentes; hace falta inspeccionar la
16
+ * estructura del HTML (anidamiento de tags) para blindarlo.
17
+ *
18
+ * Camina el HTML balanceando aperturas/cierres de `<div>`/`<aside>` y
19
+ * devuelve `true` si el indice `objetivo` cae DENTRO de un elemento cuya
20
+ * clase contiene el token exacto "hidden" (desktop-only sin `md:` que lo
21
+ * reactive dentro del MISMO elemento no importa aqui: lo que nos interesa
22
+ * es si el propio contenedor tiene `display:none` en mobile).
23
+ */
24
+ function estaAnidadoEnOculto(html: string, objetivo: number): boolean {
25
+ const tagRe = /<(div|aside)\b[^>]*?(\/)?>|<\/(div|aside)>/g
26
+ const pila: { oculto: boolean; inicio: number }[] = []
27
+ let match: RegExpExecArray | null
28
+ while ((match = tagRe.exec(html))) {
29
+ const esCierre = match[0].startsWith("</")
30
+ if (esCierre) {
31
+ const marco = pila.pop()
32
+ if (marco?.oculto && objetivo > marco.inicio && objetivo < match.index) {
33
+ return true
34
+ }
35
+ continue
36
+ }
37
+ const esAutoCerrado = match[2] === "/"
38
+ const claseMatch = match[0].match(/\bclass="([^"]*)"/)
39
+ const esOculto = claseMatch ? claseMatch[1].split(/\s+/).includes("hidden") : false
40
+ if (!esAutoCerrado) {
41
+ pila.push({ oculto: esOculto, inicio: match.index })
42
+ }
43
+ }
44
+ return false
45
+ }
46
+
47
+ const configBase: PlpConfig = {
48
+ filtros: "sidebar",
49
+ columnas: 4,
50
+ card: "quick-add",
51
+ paginacion: "numerada",
52
+ mostrarContador: true,
53
+ vista: "grilla",
54
+ }
55
+
56
+ describe("PlpLayout", () => {
57
+ it("renderiza un solo <h1> con el titulo, el Breadcrumb, el contador, las pills activas y una card por producto", () => {
58
+ const listado = fixtureListado({ seleccion: { categoria: ["camisetas"] }, total: 128 })
59
+ const ctx = mockCtx({ catalogo: { listado } })
60
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={configBase} />)
61
+
62
+ const h1s = html.match(/<h1[\s>]/g) ?? []
63
+ expect(h1s).toHaveLength(1)
64
+ expect(html).toContain(listado.titulo)
65
+ expect(html).toContain("<nav")
66
+ expect(html).toContain('aria-label="Ruta"')
67
+ expect(html).toContain("128 productos")
68
+ expect(html).toContain("Limpiar todo") // PillsActivas
69
+ for (const producto of listado.productos) {
70
+ expect(html).toContain(producto.title)
71
+ }
72
+ })
73
+
74
+ it("con ctx.catalogo undefined devuelve null", () => {
75
+ const ctx = mockCtx()
76
+ const resultado = PlpLayout({ ctx, config: configBase })
77
+
78
+ expect(resultado).toBeNull()
79
+ })
80
+ })
81
+
82
+ describe("PlpLayout despacha el arreglo desktop por config.filtros", () => {
83
+ const marcadores = ["sidebar", "pills", "overlay", "inline"] as const
84
+
85
+ it.each(marcadores)('config.filtros:"%s" renderiza SOLO ese arreglo (data-arreglo)', (filtros) => {
86
+ const listado = fixtureListado()
87
+ const ctx = mockCtx({ catalogo: { listado } })
88
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, filtros }} />)
89
+
90
+ expect(html).toContain(`data-arreglo="${filtros}"`)
91
+ for (const otro of marcadores) {
92
+ if (otro === filtros) continue
93
+ expect(html).not.toContain(`data-arreglo="${otro}"`)
94
+ }
95
+ })
96
+
97
+ it.each(marcadores)('config.filtros:"%s" siempre renderiza la convergencia mobile (BarraFiltrosMobil, md:hidden)', (filtros) => {
98
+ const listado = fixtureListado()
99
+ const ctx = mockCtx({ catalogo: { listado } })
100
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, filtros }} />)
101
+
102
+ expect(html).toMatch(/Filtrar\s*\(0\)/)
103
+ expect(html).toContain("Ordenar")
104
+ expect(html).toContain("md:hidden")
105
+ })
106
+
107
+ it("con config.filtros inválido degrada a sidebar (no crashea)", () => {
108
+ const listado = fixtureListado()
109
+ const ctx = mockCtx({ catalogo: { listado } })
110
+ const html = renderToStaticMarkup(
111
+ <PlpLayout ctx={ctx} config={{ ...configBase, filtros: "inexistente" as any }} />,
112
+ )
113
+
114
+ expect(html).toContain(`data-arreglo="sidebar"`)
115
+ })
116
+
117
+ it("la grilla usa config.columnas y config.card", () => {
118
+ const listado = fixtureListado()
119
+ const ctx = mockCtx({ catalogo: { listado } })
120
+ const html = renderToStaticMarkup(
121
+ <PlpLayout ctx={ctx} config={{ ...configBase, filtros: "sidebar", columnas: 3, card: "minimal" }} />,
122
+ )
123
+
124
+ expect(html).toContain("md:grid-cols-3")
125
+ expect(html).not.toContain("Añadir") // card "minimal" no tiene boton quick-add
126
+ })
127
+
128
+ it("la paginacion usa config.paginacion (load-more -> Cargar más, sin nav numerada)", () => {
129
+ const listado = fixtureListado()
130
+ const ctx = mockCtx({ catalogo: { listado } })
131
+ const html = renderToStaticMarkup(
132
+ <PlpLayout ctx={ctx} config={{ ...configBase, filtros: "sidebar", paginacion: "load-more" }} />,
133
+ )
134
+
135
+ expect(html).toContain("Cargar más")
136
+ expect(html).not.toContain('aria-label="Paginación"')
137
+ })
138
+
139
+ it("la paginacion usa config.paginacion (numerada -> nav aria-label Paginación)", () => {
140
+ const listado = fixtureListado()
141
+ const ctx = mockCtx({ catalogo: { listado } })
142
+ const html = renderToStaticMarkup(
143
+ <PlpLayout ctx={ctx} config={{ ...configBase, filtros: "sidebar", paginacion: "numerada" }} />,
144
+ )
145
+
146
+ expect(html).toContain('aria-label="Paginación"')
147
+ expect(html).not.toContain("Cargar más")
148
+ })
149
+ })
150
+
151
+ describe("regresion 4d-ii: la grilla (FrameProductos) es siempre visible, tambien en mobile", () => {
152
+ const marcadores = ["sidebar", "pills", "overlay", "inline"] as const
153
+
154
+ it.each(marcadores)(
155
+ 'config.filtros:"%s": el marcador data-grid de la grilla NO esta anidado dentro de un contenedor con clase "hidden" (desktop-only)',
156
+ (filtros) => {
157
+ const listado = fixtureListado()
158
+ const ctx = mockCtx({ catalogo: { listado } })
159
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, filtros }} />)
160
+
161
+ const idxGrid = html.indexOf("data-grid")
162
+ expect(idxGrid).toBeGreaterThan(-1)
163
+ expect(estaAnidadoEnOculto(html, idxGrid)).toBe(false)
164
+ },
165
+ )
166
+
167
+ it("la grilla (data-grid) y la barra mobile (Filtrar/Ordenar, md:hidden) coexisten: ninguna reemplaza a la otra", () => {
168
+ const listado = fixtureListado()
169
+ const ctx = mockCtx({ catalogo: { listado } })
170
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={configBase} />)
171
+
172
+ expect(html).toContain("data-grid")
173
+ expect(html).toMatch(/Filtrar\s*\(0\)/)
174
+ })
175
+
176
+ it('config.filtros:"sidebar": la grilla viene DESPUES de que cierra el <aside> desktop-only, no anidada dentro de el', () => {
177
+ const listado = fixtureListado()
178
+ const ctx = mockCtx({ catalogo: { listado } })
179
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, filtros: "sidebar" }} />)
180
+
181
+ const cierreAside = html.indexOf("</aside>")
182
+ const idxGrid = html.indexOf("data-grid")
183
+ expect(cierreAside).toBeGreaterThan(-1)
184
+ expect(idxGrid).toBeGreaterThan(cierreAside)
185
+ })
186
+ })
187
+
188
+ describe("FrameProductos", () => {
189
+ it("con columnas:4 aplica grid-cols-2 (mobile) y md:grid-cols-4, con una card por producto + Paginacion", () => {
190
+ const listado = fixtureListado()
191
+ const ctx = mockCtx()
192
+ const html = renderToStaticMarkup(
193
+ <FrameProductos listado={listado} columnas={4} card="quick-add" ctx={ctx} />,
194
+ )
195
+
196
+ expect(html).toContain("grid-cols-2")
197
+ expect(html).toContain("md:grid-cols-4")
198
+ for (const producto of listado.productos) {
199
+ expect(html).toContain(producto.title)
200
+ }
201
+ expect(html).toContain('aria-label="Paginación"')
202
+ })
203
+
204
+ it("con columnas:3 aplica md:grid-cols-3", () => {
205
+ const listado = fixtureListado()
206
+ const ctx = mockCtx()
207
+ const html = renderToStaticMarkup(
208
+ <FrameProductos listado={listado} columnas={3} card="minimal" ctx={ctx} />,
209
+ )
210
+
211
+ expect(html).toContain("md:grid-cols-3")
212
+ })
213
+
214
+ it('con paginacion:"load-more" renderiza el boton "Cargar más" en vez de la nav numerada', () => {
215
+ const listado = fixtureListado()
216
+ const ctx = mockCtx()
217
+ const html = renderToStaticMarkup(
218
+ <FrameProductos listado={listado} columnas={4} card="quick-add" ctx={ctx} paginacion="load-more" />,
219
+ )
220
+
221
+ expect(html).toContain("Cargar más")
222
+ expect(html).not.toContain('aria-label="Paginación"')
223
+ })
224
+ })
225
+
226
+ describe("vista lista vs grilla (config.vista)", () => {
227
+ it('config.vista:"lista" renderiza filas (data-lista) y NO la grilla (data-grid)', () => {
228
+ const listado = fixtureListado()
229
+ const ctx = mockCtx({ catalogo: { listado } })
230
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, vista: "lista" }} />)
231
+
232
+ expect(html).toContain("data-lista")
233
+ expect(html).not.toContain("data-grid")
234
+ // cada producto sigue apareciendo, ahora como fila (data-card="lista")
235
+ expect(html).toContain('data-card="lista"')
236
+ for (const producto of listado.productos) {
237
+ expect(html).toContain(producto.title)
238
+ }
239
+ })
240
+
241
+ it('config.vista:"grilla" (default) renderiza la grilla (data-grid) y NO filas (data-lista)', () => {
242
+ const listado = fixtureListado()
243
+ const ctx = mockCtx({ catalogo: { listado } })
244
+ const html = renderToStaticMarkup(<PlpLayout ctx={ctx} config={{ ...configBase, vista: "grilla" }} />)
245
+
246
+ expect(html).toContain("data-grid")
247
+ expect(html).not.toContain("data-lista")
248
+ })
249
+
250
+ it("FrameProductos vista:'lista' produce un layout distinto al de grilla", () => {
251
+ const listado = fixtureListado()
252
+ const ctx = mockCtx()
253
+ const grilla = renderToStaticMarkup(
254
+ <FrameProductos listado={listado} columnas={4} card="quick-add" ctx={ctx} vista="grilla" />,
255
+ )
256
+ const lista = renderToStaticMarkup(
257
+ <FrameProductos listado={listado} columnas={4} card="quick-add" ctx={ctx} vista="lista" />,
258
+ )
259
+
260
+ expect(grilla).toContain("data-grid")
261
+ expect(grilla).not.toContain("data-lista")
262
+ expect(lista).toContain("data-lista")
263
+ expect(lista).not.toContain("data-grid")
264
+ expect(lista).not.toBe(grilla)
265
+ })
266
+ })
267
+
268
+ describe("BarraFiltrosMobil", () => {
269
+ it('renderiza "Filtrar (n)" y "Ordenar" y es md:hidden', () => {
270
+ const listado = fixtureListado()
271
+ const ctx = mockCtx()
272
+ const html = renderToStaticMarkup(
273
+ <BarraFiltrosMobil listado={listado} ctx={ctx} cantidadFiltros={1} />,
274
+ )
275
+
276
+ expect(html).toMatch(/Filtrar\s*\(1\)/)
277
+ expect(html).toContain("Ordenar")
278
+ expect(html).toContain("md:hidden")
279
+ })
280
+ })
281
+
282
+ describe("BottomSheetFiltros", () => {
283
+ it("abierto:true renderiza el panel: grabber, Limpiar todo, una sección por faceta válida, CTA Ver N productos", () => {
284
+ const listado = fixtureListado({ total: 128 })
285
+ const ctx = mockCtx()
286
+ const html = renderToStaticMarkup(
287
+ <BottomSheetFiltros listado={listado} ctx={ctx} abierto={true} onCerrar={() => {}} />,
288
+ )
289
+
290
+ expect(html).toContain("data-grabber")
291
+ expect(html).toContain("Limpiar todo")
292
+ // fixtureFiltros: categoria (pill/tag, válida) y precio (range/price, válida)
293
+ expect(html).toContain('data-facet="categoria"')
294
+ expect(html).toContain('data-facet="precio"')
295
+ expect(html).toContain("Ver 128 productos")
296
+ })
297
+
298
+ it("abierto:false no renderiza el panel", () => {
299
+ const listado = fixtureListado()
300
+ const ctx = mockCtx()
301
+ const html = renderToStaticMarkup(
302
+ <BottomSheetFiltros listado={listado} ctx={ctx} abierto={false} onCerrar={() => {}} />,
303
+ )
304
+
305
+ expect(html).toBe("")
306
+ })
307
+ })
308
+
309
+ describe("guard de estandar (cero <img>/<a> directos, un solo <h1>)", () => {
310
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
311
+ expect(codigoFuente).not.toMatch(/<img[\s>]/)
312
+ expect(codigoFuente).not.toMatch(/<a[\s>]/)
313
+ })
314
+
315
+ it("exactamente un <h1> en el codigo fuente", () => {
316
+ const h1s = codigoFuente.match(/<h1[\s>]/g) ?? []
317
+ expect(h1s).toHaveLength(1)
318
+ })
319
+ })
@@ -0,0 +1,259 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { facetControlEsValido } from "@arroyavecommerce/cms-core"
5
+ import type { RenderContext } from "../context"
6
+ import { TarjetaProducto, type CardVariant } from "./product-card"
7
+ import { ControlDeFiltro } from "./filter-controls"
8
+ import { Breadcrumb, ToolbarPlp, PillsActivas, Paginacion } from "./plp-parts"
9
+ import { ArregloSidebar } from "./plp-sidebar"
10
+ import { ArregloInline } from "./plp-inline"
11
+ import { ArregloPills } from "./plp-pills"
12
+ import { ArregloOverlay } from "./plp-overlay"
13
+
14
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
15
+
16
+ export type PlpConfig = {
17
+ filtros: "sidebar" | "pills" | "overlay" | "inline"
18
+ columnas: 3 | 4
19
+ card: CardVariant
20
+ paginacion: "numerada" | "load-more"
21
+ mostrarContador: boolean
22
+ vista: "grilla" | "lista"
23
+ }
24
+
25
+ /** Facetas válidas (control×fuente) de listado.filtros, ordenadas por `order`. */
26
+ export function facetsValidas(listado: ListadoPlp) {
27
+ return [...listado.filtros.facets]
28
+ .filter((facet) => facetControlEsValido(facet))
29
+ .sort((a, b) => a.order - b.order)
30
+ }
31
+
32
+ /**
33
+ * Productos del PLP + Paginacion debajo. `vista` decide el layout:
34
+ * - "grilla" (default): grilla responsive (mobile 2 col → md:columnas col).
35
+ * - "lista": filas horizontales (imagen chica + título/precio/CTA en línea),
36
+ * una debajo de otra, en cualquier ancho.
37
+ * (mockup PLP l.83-100).
38
+ */
39
+ export function FrameProductos({
40
+ listado,
41
+ columnas,
42
+ card,
43
+ ctx,
44
+ paginacion = "numerada",
45
+ vista = "grilla",
46
+ }: {
47
+ listado: ListadoPlp
48
+ columnas: 3 | 4
49
+ card: CardVariant
50
+ ctx: RenderContext
51
+ paginacion?: "numerada" | "load-more"
52
+ vista?: "grilla" | "lista"
53
+ }) {
54
+ return (
55
+ <div className="flex flex-col gap-6">
56
+ {vista === "lista" ? (
57
+ <div data-lista className="flex w-full flex-col divide-y" style={{ borderColor: "var(--cms-borde)" }}>
58
+ {listado.productos.map((product) => (
59
+ <div key={product.id} className="py-4 first:pt-0">
60
+ <TarjetaProducto product={product} card={card} ctx={ctx} vista="lista" />
61
+ </div>
62
+ ))}
63
+ </div>
64
+ ) : (
65
+ <div
66
+ data-grid
67
+ className={`grid w-full grid-cols-2 gap-4 md:gap-5 ${
68
+ columnas === 3 ? "md:grid-cols-3" : "md:grid-cols-4"
69
+ }`}
70
+ >
71
+ {listado.productos.map((product) => (
72
+ <TarjetaProducto key={product.id} product={product} card={card} ctx={ctx} />
73
+ ))}
74
+ </div>
75
+ )}
76
+ <Paginacion listado={listado} ctx={ctx} modo={paginacion} />
77
+ </div>
78
+ )
79
+ }
80
+
81
+ /** Bottom-sheet de filtros (scrim + panel inferior): grabber, "Limpiar todo", una sección por faceta válida, CTA. */
82
+ export function BottomSheetFiltros({
83
+ listado,
84
+ ctx,
85
+ abierto,
86
+ onCerrar,
87
+ }: {
88
+ listado: ListadoPlp
89
+ ctx: RenderContext
90
+ abierto: boolean
91
+ onCerrar: () => void
92
+ }) {
93
+ if (!abierto) return null
94
+
95
+ const { Enlace } = ctx.componentes
96
+
97
+ return (
98
+ <>
99
+ <div className="fixed inset-0 z-40 bg-black/50 md:hidden" onClick={onCerrar} />
100
+ <div
101
+ className="fixed inset-x-0 bottom-0 z-50 flex max-h-[85vh] flex-col gap-4 overflow-y-auto px-5 pb-5 pt-3.5 md:hidden"
102
+ style={{
103
+ background: "var(--cms-superficie)",
104
+ color: "var(--cms-texto)",
105
+ borderRadius: "14px 14px 0 0",
106
+ }}
107
+ >
108
+ <div
109
+ aria-hidden="true"
110
+ data-grabber
111
+ className="mx-auto h-1 w-10 rounded-full"
112
+ style={{ background: "var(--cms-borde)" }}
113
+ />
114
+ <div className="flex items-center justify-between">
115
+ <span className="text-[15px] font-semibold">Filtros</span>
116
+ <Enlace
117
+ href="?"
118
+ className="text-[12px] underline [color:var(--cms-texto-suave)] [text-decoration:underline]"
119
+ >
120
+ Limpiar todo
121
+ </Enlace>
122
+ </div>
123
+ {facetsValidas(listado).map((facet) => (
124
+ <div
125
+ key={facet.id}
126
+ data-facet={facet.id}
127
+ className="flex flex-col gap-2.5 [border-top:var(--cms-grosor-borde)_solid_var(--cms-borde)] pt-3.5"
128
+ >
129
+ <ControlDeFiltro
130
+ facet={facet}
131
+ opciones={listado.opcionesFaceta[facet.id] ?? []}
132
+ seleccionados={listado.seleccion[facet.id] ?? []}
133
+ urlFiltro={listado.urlFiltro}
134
+ ctx={ctx}
135
+ />
136
+ </div>
137
+ ))}
138
+ <button
139
+ type="button"
140
+ onClick={onCerrar}
141
+ className="mt-2 py-3.5 text-center text-[12.5px] font-semibold uppercase tracking-[0.06em]"
142
+ style={{
143
+ background: "var(--cms-primario)",
144
+ color: "var(--cms-primario-texto)",
145
+ borderRadius: "var(--cms-radio)",
146
+ fontFamily: "var(--cms-fuente-texto)",
147
+ }}
148
+ >
149
+ Ver {listado.total} productos
150
+ </button>
151
+ </div>
152
+ </>
153
+ )
154
+ }
155
+
156
+ /** Barra sticky inferior "Filtrar (n)" | "Ordenar" (mockup PLP l.226-229); abre BottomSheetFiltros. */
157
+ export function BarraFiltrosMobil({
158
+ listado,
159
+ ctx,
160
+ cantidadFiltros,
161
+ }: {
162
+ listado: ListadoPlp
163
+ ctx: RenderContext
164
+ cantidadFiltros: number
165
+ }) {
166
+ const [abierto, setAbierto] = useState(false)
167
+
168
+ return (
169
+ <>
170
+ <div
171
+ className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-2 md:hidden"
172
+ style={{ background: "var(--cms-superficie)", borderTop: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
173
+ >
174
+ <button
175
+ type="button"
176
+ onClick={() => setAbierto(true)}
177
+ aria-expanded={abierto}
178
+ className="py-[15px] text-center text-[12.5px] font-semibold [border-right:var(--cms-grosor-borde)_solid_var(--cms-borde)]"
179
+ >
180
+ <span aria-hidden="true">⚌</span> Filtrar ({cantidadFiltros})
181
+ </button>
182
+ <button type="button" className="py-[15px] text-center text-[12.5px] font-semibold">
183
+ <span aria-hidden="true">⇅</span> Ordenar
184
+ </button>
185
+ </div>
186
+ <BottomSheetFiltros listado={listado} ctx={ctx} abierto={abierto} onCerrar={() => setAbierto(false)} />
187
+ </>
188
+ )
189
+ }
190
+
191
+ /** Cantidad de facetas con al menos un valor seleccionado (para el badge "Filtrar (n)"). */
192
+ function contarFiltrosActivos(listado: ListadoPlp): number {
193
+ return Object.values(listado.seleccion).filter((valores) => valores.length > 0).length
194
+ }
195
+
196
+ /**
197
+ * Mapa `config.filtros` → arreglo desktop correspondiente. Cada arreglo solo
198
+ * oculta su propio CHROME de filtros en mobile (`hidden md:...`); el
199
+ * `children` que recibe (la grilla) NUNCA queda dentro de ese contenedor
200
+ * oculto, así la grilla permanece visible en mobile.
201
+ */
202
+ const ARREGLOS_POR_FILTROS = {
203
+ sidebar: ArregloSidebar,
204
+ inline: ArregloInline,
205
+ pills: ArregloPills,
206
+ overlay: ArregloOverlay,
207
+ } as const
208
+
209
+ /**
210
+ * Frame compartido del PLP: breadcrumb + h1 + toolbar + pills activas + el
211
+ * arreglo desktop de filtros elegido por `config.filtros` (slot único: un solo
212
+ * arreglo se renderiza, pero SIEMPRE recibe la grilla `FrameProductos` como
213
+ * `children` y es responsable de mostrarla — solo su chrome de filtros es
214
+ * desktop-only) + la convergencia mobile (BarraFiltrosMobil + BottomSheetFiltros),
215
+ * que se renderiza SIEMPRE sin importar `config.filtros`.
216
+ */
217
+ export function PlpLayout({ ctx, config }: { ctx: RenderContext; config: PlpConfig }) {
218
+ const listado = ctx.catalogo?.listado
219
+ if (!listado) return null
220
+
221
+ const ArregloFiltros = ARREGLOS_POR_FILTROS[config.filtros] ?? ArregloSidebar
222
+
223
+ return (
224
+ <section className="flex flex-col gap-5 px-4 py-6 md:px-7 md:py-8">
225
+ <Breadcrumb items={listado.breadcrumb} ctx={ctx} />
226
+ <div className="flex items-baseline justify-between gap-4">
227
+ <h1
228
+ className="m-0 text-[26px] md:text-[34px]"
229
+ style={{
230
+ fontFamily: "var(--cms-fuente-titulo)",
231
+ fontWeight: "var(--cms-titulo-peso)",
232
+ textTransform: "var(--cms-titulo-transform)",
233
+ letterSpacing: "var(--cms-titulo-espaciado)",
234
+ }}
235
+ >
236
+ {listado.titulo}
237
+ </h1>
238
+ <ToolbarPlp
239
+ listado={listado}
240
+ ctx={ctx}
241
+ mostrarContador={config.mostrarContador}
242
+ vista={config.vista}
243
+ />
244
+ </div>
245
+ <PillsActivas listado={listado} ctx={ctx} />
246
+ <ArregloFiltros listado={listado} ctx={ctx}>
247
+ <FrameProductos
248
+ listado={listado}
249
+ columnas={config.columnas}
250
+ card={config.card}
251
+ paginacion={config.paginacion}
252
+ vista={config.vista}
253
+ ctx={ctx}
254
+ />
255
+ </ArregloFiltros>
256
+ <BarraFiltrosMobil listado={listado} ctx={ctx} cantidadFiltros={contarFiltrosActivos(listado)} />
257
+ </section>
258
+ )
259
+ }