@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,235 @@
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 { ArregloSidebar } from "./plp-sidebar"
6
+ import { ArregloInline } from "./plp-inline"
7
+ import { ArregloPills } from "./plp-pills"
8
+ import { ArregloOverlay, PanelFiltrosOverlay } from "./plp-overlay"
9
+ import { mockCtx, fixtureListado } from "../test/mock-ctx"
10
+
11
+ const codigoFuenteSidebar = readFileSync(fileURLToPath(new URL("./plp-sidebar.tsx", import.meta.url)), "utf-8")
12
+ const codigoFuenteInline = readFileSync(fileURLToPath(new URL("./plp-inline.tsx", import.meta.url)), "utf-8")
13
+ const codigoFuentePills = readFileSync(fileURLToPath(new URL("./plp-pills.tsx", import.meta.url)), "utf-8")
14
+ const codigoFuenteOverlay = readFileSync(fileURLToPath(new URL("./plp-overlay.tsx", import.meta.url)), "utf-8")
15
+
16
+ /** Stub de grilla: lo que `PlpLayout` le pasaría como `children` (la grilla real). */
17
+ const gridStub = <div data-grid-stub>grid-stub</div>
18
+
19
+ describe("ArregloSidebar", () => {
20
+ it("aside a la izquierda con una seccion por faceta valida + children (la grilla) a la derecha (240px 1fr)", () => {
21
+ const listado = fixtureListado()
22
+ const ctx = mockCtx()
23
+ const html = renderToStaticMarkup(
24
+ <ArregloSidebar listado={listado} ctx={ctx}>
25
+ {gridStub}
26
+ </ArregloSidebar>,
27
+ )
28
+
29
+ expect(html).toContain("<aside")
30
+ // fixtureFiltros: categoria (pill/tag) y precio (range/price), ambas validas
31
+ expect(html).toContain('data-facet="categoria"')
32
+ expect(html).toContain('data-facet="precio"')
33
+ expect(html).toContain("Categoría")
34
+ expect(html).toContain("Precio")
35
+ expect(html).toMatch(/240px[_\s]1fr|grid-cols-\[240px_1fr\]/)
36
+ expect(html).toContain("data-grid-stub")
37
+ })
38
+
39
+ it("el <aside> de filtros es hidden md:flex (desktop-only); children (la grilla) no queda dentro de hidden", () => {
40
+ const listado = fixtureListado()
41
+ const ctx = mockCtx()
42
+ const html = renderToStaticMarkup(
43
+ <ArregloSidebar listado={listado} ctx={ctx}>
44
+ {gridStub}
45
+ </ArregloSidebar>,
46
+ )
47
+
48
+ const asideMatch = html.match(/<aside[^>]*>/)
49
+ expect(asideMatch?.[0]).toContain("hidden")
50
+ expect(asideMatch?.[0]).toContain("md:flex")
51
+
52
+ // El marcador de la grilla no debe caer dentro de ningun contenedor `hidden` sin `md:` que lo re-muestre.
53
+ const indiceGrid = html.indexOf("data-grid-stub")
54
+ const indiceAside = html.indexOf("<aside")
55
+ const indiceCierreAside = html.indexOf("</aside>")
56
+ expect(indiceGrid).toBeGreaterThan(indiceCierreAside)
57
+ expect(indiceGrid > indiceAside && indiceGrid < indiceCierreAside).toBe(false)
58
+ })
59
+ })
60
+
61
+ describe("ArregloInline", () => {
62
+ it("fila de filtros integrada sobre children (la grilla): una faceta por celda", () => {
63
+ const listado = fixtureListado()
64
+ const ctx = mockCtx()
65
+ const html = renderToStaticMarkup(
66
+ <ArregloInline listado={listado} ctx={ctx}>
67
+ {gridStub}
68
+ </ArregloInline>,
69
+ )
70
+
71
+ expect(html).toContain('data-facet="categoria"')
72
+ expect(html).toContain('data-facet="precio"')
73
+ expect(html).toContain("data-grid-stub")
74
+ })
75
+
76
+ it("la fila de filtros es hidden md:grid (desktop-only); children (la grilla) no queda dentro de hidden", () => {
77
+ const listado = fixtureListado()
78
+ const ctx = mockCtx()
79
+ const html = renderToStaticMarkup(
80
+ <ArregloInline listado={listado} ctx={ctx}>
81
+ {gridStub}
82
+ </ArregloInline>,
83
+ )
84
+
85
+ expect(html).toContain("hidden")
86
+ expect(html).toContain("md:grid")
87
+
88
+ const indiceGridStub = html.indexOf("data-grid-stub")
89
+ const indiceFacet = html.indexOf('data-facet="categoria"')
90
+ expect(indiceGridStub).toBeGreaterThan(indiceFacet)
91
+ })
92
+ })
93
+
94
+ describe("ArregloPills", () => {
95
+ it("barra horizontal con un chip/dropdown por faceta valida + children (la grilla)", () => {
96
+ const listado = fixtureListado()
97
+ const ctx = mockCtx()
98
+ const html = renderToStaticMarkup(
99
+ <ArregloPills listado={listado} ctx={ctx}>
100
+ {gridStub}
101
+ </ArregloPills>,
102
+ )
103
+
104
+ // fixtureFiltros: categoria (pill/tag) y precio (range/price), ambas validas
105
+ expect(html).toContain('data-facet="categoria"')
106
+ expect(html).toContain('data-facet="precio"')
107
+ expect(html).toContain("Categoría")
108
+ expect(html).toContain("Precio")
109
+ expect(html).toContain("data-grid-stub")
110
+ })
111
+
112
+ it("la barra de chips es hidden md:flex (desktop-only); children (la grilla) no queda dentro de hidden", () => {
113
+ const listado = fixtureListado()
114
+ const ctx = mockCtx()
115
+ const html = renderToStaticMarkup(
116
+ <ArregloPills listado={listado} ctx={ctx}>
117
+ {gridStub}
118
+ </ArregloPills>,
119
+ )
120
+
121
+ expect(html).toContain("hidden")
122
+ expect(html).toContain("md:flex")
123
+
124
+ const indiceGridStub = html.indexOf("data-grid-stub")
125
+ const indiceFacet = html.indexOf('data-facet="categoria"')
126
+ expect(indiceGridStub).toBeGreaterThan(indiceFacet)
127
+ })
128
+ })
129
+
130
+ describe("ArregloOverlay", () => {
131
+ it('boton "Filtros (n)" con n = cantidad de facetas con seleccion no vacia + children (la grilla)', () => {
132
+ const listado = fixtureListado({ seleccion: { categoria: ["camisetas"] } })
133
+ const ctx = mockCtx()
134
+ const html = renderToStaticMarkup(
135
+ <ArregloOverlay listado={listado} ctx={ctx}>
136
+ {gridStub}
137
+ </ArregloOverlay>,
138
+ )
139
+
140
+ expect(html).toMatch(/Filtros\s*\(1\)/)
141
+ expect(html).toContain("data-grid-stub")
142
+ })
143
+
144
+ it("con ninguna faceta seleccionada el boton muestra Filtros (0)", () => {
145
+ const listado = fixtureListado()
146
+ const ctx = mockCtx()
147
+ const html = renderToStaticMarkup(
148
+ <ArregloOverlay listado={listado} ctx={ctx}>
149
+ {gridStub}
150
+ </ArregloOverlay>,
151
+ )
152
+
153
+ expect(html).toMatch(/Filtros\s*\(0\)/)
154
+ })
155
+
156
+ it("cerrado por defecto: el panel no esta en el DOM", () => {
157
+ const listado = fixtureListado({ total: 128 })
158
+ const ctx = mockCtx()
159
+ const html = renderToStaticMarkup(
160
+ <ArregloOverlay listado={listado} ctx={ctx}>
161
+ {gridStub}
162
+ </ArregloOverlay>,
163
+ )
164
+
165
+ expect(html).not.toContain("Ver 128 productos")
166
+ expect(html).not.toContain("Limpiar")
167
+ })
168
+
169
+ it('el boton "Filtros (n)" es hidden md:flex (desktop-only); children (la grilla) no queda dentro de hidden', () => {
170
+ const listado = fixtureListado()
171
+ const ctx = mockCtx()
172
+ const html = renderToStaticMarkup(
173
+ <ArregloOverlay listado={listado} ctx={ctx}>
174
+ {gridStub}
175
+ </ArregloOverlay>,
176
+ )
177
+
178
+ expect(html).toContain("hidden")
179
+ expect(html).toContain("md:flex")
180
+
181
+ const indiceGridStub = html.indexOf("data-grid-stub")
182
+ const indiceBoton = html.indexOf("Filtros (")
183
+ expect(indiceGridStub).toBeGreaterThan(indiceBoton)
184
+ })
185
+
186
+ it("PanelFiltrosOverlay abierto:true renderiza scrim, una seccion por faceta valida, Limpiar y Ver N productos", () => {
187
+ const listado = fixtureListado({ total: 128 })
188
+ const ctx = mockCtx()
189
+ const html = renderToStaticMarkup(
190
+ <PanelFiltrosOverlay listado={listado} ctx={ctx} abierto={true} onCerrar={() => {}} />,
191
+ )
192
+
193
+ expect(html).toContain('data-facet="categoria"')
194
+ expect(html).toContain('data-facet="precio"')
195
+ expect(html).toContain("Limpiar")
196
+ expect(html).toContain("Ver 128 productos")
197
+ expect(html).toContain("bg-black/50")
198
+ })
199
+
200
+ it("PanelFiltrosOverlay abierto:false no renderiza el panel", () => {
201
+ const listado = fixtureListado()
202
+ const ctx = mockCtx()
203
+ const html = renderToStaticMarkup(
204
+ <PanelFiltrosOverlay listado={listado} ctx={ctx} abierto={false} onCerrar={() => {}} />,
205
+ )
206
+
207
+ expect(html).toBe("")
208
+ })
209
+ })
210
+
211
+ describe("guard de estandar (cero <img>/<a> directos, sin <h1>)", () => {
212
+ it("plp-sidebar: no emite <img> ni <a> directos, ni <h1>", () => {
213
+ expect(codigoFuenteSidebar).not.toMatch(/<img[\s>]/)
214
+ expect(codigoFuenteSidebar).not.toMatch(/<a[\s>]/)
215
+ expect(codigoFuenteSidebar).not.toMatch(/<h1[\s>]/)
216
+ })
217
+
218
+ it("plp-inline: no emite <img> ni <a> directos, ni <h1>", () => {
219
+ expect(codigoFuenteInline).not.toMatch(/<img[\s>]/)
220
+ expect(codigoFuenteInline).not.toMatch(/<a[\s>]/)
221
+ expect(codigoFuenteInline).not.toMatch(/<h1[\s>]/)
222
+ })
223
+
224
+ it("plp-pills: no emite <img> ni <a> directos, ni <h1>", () => {
225
+ expect(codigoFuentePills).not.toMatch(/<img[\s>]/)
226
+ expect(codigoFuentePills).not.toMatch(/<a[\s>]/)
227
+ expect(codigoFuentePills).not.toMatch(/<h1[\s>]/)
228
+ })
229
+
230
+ it("plp-overlay: no emite <img> ni <a> directos, ni <h1>", () => {
231
+ expect(codigoFuenteOverlay).not.toMatch(/<img[\s>]/)
232
+ expect(codigoFuenteOverlay).not.toMatch(/<a[\s>]/)
233
+ expect(codigoFuenteOverlay).not.toMatch(/<h1[\s>]/)
234
+ })
235
+ })
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from "react"
2
+ import type { RenderContext } from "../context"
3
+ import { ControlDeFiltro } from "./filter-controls"
4
+ import { facetsValidas } from "./plp"
5
+
6
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
7
+
8
+ /**
9
+ * Arreglo desktop "inline" (mockup PLP l.176-197): fila de filtros integrada
10
+ * sobre `children` (la grilla, provista por `PlpLayout`), una faceta por
11
+ * celda con bordes compartidos (estética utility). Solo la fila de filtros
12
+ * es desktop-only (`hidden md:block`): el filtrado mobile va por el
13
+ * bottom-sheet. `children` NO queda dentro de ningún contenedor `hidden`.
14
+ */
15
+ export function ArregloInline({
16
+ listado,
17
+ ctx,
18
+ children,
19
+ }: {
20
+ listado: ListadoPlp
21
+ ctx: RenderContext
22
+ children: ReactNode
23
+ }) {
24
+ const facets = facetsValidas(listado)
25
+
26
+ return (
27
+ <div data-arreglo="inline" className="flex flex-col gap-5">
28
+ <div
29
+ className="hidden [border:var(--cms-grosor-borde)_solid_var(--cms-borde)] md:grid"
30
+ style={{ gridTemplateColumns: `repeat(${facets.length}, 1fr)` }}
31
+ >
32
+ {facets.map((facet, indice) => (
33
+ <div
34
+ key={facet.id}
35
+ data-facet={facet.id}
36
+ className={`p-4 text-[11px] font-medium uppercase tracking-[0.06em] ${
37
+ indice === facets.length - 1 ? "" : "[border-right:var(--cms-grosor-borde)_solid_var(--cms-borde)]"
38
+ }`}
39
+ style={{ fontFamily: "var(--cms-fuente-mono)" }}
40
+ >
41
+ <ControlDeFiltro
42
+ facet={facet}
43
+ opciones={listado.opcionesFaceta[facet.id] ?? []}
44
+ seleccionados={listado.seleccion[facet.id] ?? []}
45
+ urlFiltro={listado.urlFiltro}
46
+ ctx={ctx}
47
+ />
48
+ </div>
49
+ ))}
50
+ </div>
51
+ {children}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,129 @@
1
+ "use client"
2
+
3
+ import { useState, type ReactNode } from "react"
4
+ import type { RenderContext } from "../context"
5
+ import { ControlDeFiltro } from "./filter-controls"
6
+ import { facetsValidas } from "./plp"
7
+
8
+ type ListadoPlp = NonNullable<NonNullable<RenderContext["catalogo"]>["listado"]>
9
+
10
+ /** Cantidad de facetas con al menos un valor seleccionado (para el badge "Filtros (n)"). */
11
+ function contarFiltrosActivos(listado: ListadoPlp): number {
12
+ return Object.values(listado.seleccion).filter((valores) => valores.length > 0).length
13
+ }
14
+
15
+ /**
16
+ * Panel lateral derecho full-height sobre scrim (mockup PLP l.156-172):
17
+ * una sección por faceta válida (via ControlDeFiltro) + "Limpiar"/"Ver N productos".
18
+ * `abierto:false` → no se renderiza nada (patrón de BottomSheetFiltros/DrawerMovil).
19
+ */
20
+ export function PanelFiltrosOverlay({
21
+ listado,
22
+ ctx,
23
+ abierto,
24
+ onCerrar,
25
+ }: {
26
+ listado: ListadoPlp
27
+ ctx: RenderContext
28
+ abierto: boolean
29
+ onCerrar: () => void
30
+ }) {
31
+ if (!abierto) return null
32
+
33
+ const { Enlace } = ctx.componentes
34
+
35
+ return (
36
+ <>
37
+ <div className="fixed inset-0 z-40 bg-black/50" onClick={onCerrar} />
38
+ <div
39
+ className="fixed inset-y-0 right-0 z-50 flex h-full w-[360px] flex-col gap-[18px] overflow-y-auto p-6"
40
+ style={{
41
+ background: "var(--cms-superficie)",
42
+ color: "var(--cms-texto)",
43
+ borderLeft: "var(--cms-grosor-borde) solid var(--cms-borde)",
44
+ }}
45
+ >
46
+ <div className="flex items-center justify-between">
47
+ <span className="text-[15px] font-semibold">Filtros</span>
48
+ <button type="button" onClick={onCerrar} aria-label="Cerrar filtros" className="text-lg leading-none">
49
+ ×
50
+ </button>
51
+ </div>
52
+ {facetsValidas(listado).map((facet) => (
53
+ <div
54
+ key={facet.id}
55
+ data-facet={facet.id}
56
+ className="flex flex-col gap-2.5 [border-top:var(--cms-grosor-borde)_solid_var(--cms-borde)] pt-3.5"
57
+ >
58
+ <ControlDeFiltro
59
+ facet={facet}
60
+ opciones={listado.opcionesFaceta[facet.id] ?? []}
61
+ seleccionados={listado.seleccion[facet.id] ?? []}
62
+ urlFiltro={listado.urlFiltro}
63
+ ctx={ctx}
64
+ />
65
+ </div>
66
+ ))}
67
+ <div className="mt-auto flex gap-2.5">
68
+ <Enlace
69
+ href="?"
70
+ className="flex-1 py-[13px] text-center text-[12px] font-semibold [border:var(--cms-grosor-borde)_solid_var(--cms-texto)] [border-radius:var(--cms-radio)] [color:var(--cms-texto)] [text-decoration:none]"
71
+ >
72
+ Limpiar
73
+ </Enlace>
74
+ <button
75
+ type="button"
76
+ onClick={onCerrar}
77
+ className="flex-[1.4] py-[13px] text-center text-[12px] font-semibold uppercase tracking-[0.06em]"
78
+ style={{
79
+ background: "var(--cms-primario)",
80
+ color: "var(--cms-primario-texto)",
81
+ borderRadius: "var(--cms-radio)",
82
+ fontFamily: "var(--cms-fuente-texto)",
83
+ }}
84
+ >
85
+ Ver {listado.total} productos
86
+ </button>
87
+ </div>
88
+ </div>
89
+ </>
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Arreglo desktop "overlay" (mockup PLP l.134-174): un botón "Filtros (n)" en
95
+ * la toolbar que abre un PanelFiltrosOverlay lateral derecho sobre scrim,
96
+ * seguido de `children` (la grilla, provista por `PlpLayout`) a todo el
97
+ * ancho. Panel cerrado por defecto. Solo el botón "Filtros (n)" es
98
+ * desktop-only (`hidden md:block`): el filtrado mobile va por el
99
+ * bottom-sheet. `children` NO queda dentro de ningún contenedor `hidden`.
100
+ */
101
+ export function ArregloOverlay({
102
+ listado,
103
+ ctx,
104
+ children,
105
+ }: {
106
+ listado: ListadoPlp
107
+ ctx: RenderContext
108
+ children: ReactNode
109
+ }) {
110
+ const [abierto, setAbierto] = useState(false)
111
+ const cantidad = contarFiltrosActivos(listado)
112
+
113
+ return (
114
+ <div data-arreglo="overlay">
115
+ <div className="hidden justify-end pb-4 md:flex">
116
+ <button
117
+ type="button"
118
+ onClick={() => setAbierto(true)}
119
+ aria-expanded={abierto}
120
+ className="inline-flex items-center gap-2 px-[18px] py-[9px] text-[12px] font-semibold [border:var(--cms-grosor-borde)_solid_var(--cms-texto)] [border-radius:var(--cms-radio)] [color:var(--cms-texto)]"
121
+ >
122
+ <span aria-hidden="true">⚌</span> Filtros ({cantidad})
123
+ </button>
124
+ </div>
125
+ {children}
126
+ <PanelFiltrosOverlay listado={listado} ctx={ctx} abierto={abierto} onCerrar={() => setAbierto(false)} />
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,146 @@
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 { Breadcrumb, ToolbarPlp, PillsActivas, Paginacion } from "./plp-parts"
6
+ import { mockCtx, fixtureListado } from "../test/mock-ctx"
7
+
8
+ const codigoFuente = readFileSync(fileURLToPath(new URL("./plp-parts.tsx", import.meta.url)), "utf-8")
9
+
10
+ describe("fixtureListado (guard MINOR del review de Task 1)", () => {
11
+ it("devuelve un catalogo.listado completo con las claves esperadas", () => {
12
+ const listado = fixtureListado()
13
+
14
+ expect(listado).toHaveProperty("titulo")
15
+ expect(listado).toHaveProperty("productos")
16
+ expect(listado).toHaveProperty("total")
17
+ expect(listado).toHaveProperty("seleccion")
18
+ expect(listado).toHaveProperty("paginacion")
19
+ expect(listado).toHaveProperty("urlFiltro")
20
+ expect(typeof listado.urlFiltro).toBe("function")
21
+ expect(Array.isArray(listado.productos)).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe("Breadcrumb", () => {
26
+ it("renderiza los items (textos) dentro de un <nav>; el ultimo item no es enlace", () => {
27
+ const items = [
28
+ { texto: "Inicio", href: "/" },
29
+ { texto: "Ropa", href: "/ropa" },
30
+ { texto: "Camisetas", href: "/ropa/camisetas" },
31
+ ]
32
+ const html = renderToStaticMarkup(<Breadcrumb items={items} ctx={mockCtx()} />)
33
+
34
+ expect(html).toContain("<nav")
35
+ expect(html).toContain('aria-label="Ruta"')
36
+ expect(html).toContain("Inicio")
37
+ expect(html).toContain("Ropa")
38
+ expect(html).toContain("Camisetas")
39
+ expect(html).toContain('href="/"')
40
+ expect(html).toContain('href="/ropa"')
41
+ // el ultimo item (Camisetas) no debe tener su propio href
42
+ expect(html).not.toContain('href="/ropa/camisetas"')
43
+ })
44
+ })
45
+
46
+ describe("ToolbarPlp", () => {
47
+ it("muestra el contador de productos por defecto (mostrarContador=true)", () => {
48
+ const listado = fixtureListado({ total: 128 })
49
+ const html = renderToStaticMarkup(<ToolbarPlp listado={listado} ctx={mockCtx()} />)
50
+
51
+ expect(html).toContain("128 productos")
52
+ })
53
+
54
+ it("oculta el contador cuando mostrarContador=false", () => {
55
+ const listado = fixtureListado({ total: 128 })
56
+ const html = renderToStaticMarkup(<ToolbarPlp listado={listado} ctx={mockCtx()} mostrarContador={false} />)
57
+
58
+ expect(html).not.toContain("128 productos")
59
+ })
60
+
61
+ it('muestra "Ordenar" con el orden actual', () => {
62
+ const listado = fixtureListado({ orden: "relevancia" })
63
+ const html = renderToStaticMarkup(<ToolbarPlp listado={listado} ctx={mockCtx()} />)
64
+
65
+ expect(html).toMatch(/Ordenar/i)
66
+ expect(html).toMatch(/Relevancia/i)
67
+ })
68
+ })
69
+
70
+ describe("PillsActivas", () => {
71
+ it('con seleccion:{talla:["S"]} muestra un chip "S" con "x" (Enlace a urlFiltro)', () => {
72
+ const listado = fixtureListado({ seleccion: { talla: ["S"] } })
73
+ const html = renderToStaticMarkup(<PillsActivas listado={listado} ctx={mockCtx()} />)
74
+
75
+ expect(html).toContain("S")
76
+ expect(html).toContain("×")
77
+ expect(html).toContain('href="?talla=S"')
78
+ })
79
+
80
+ it('incluye "Limpiar todo" cuando hay seleccion activa', () => {
81
+ const listado = fixtureListado({ seleccion: { talla: ["S"] } })
82
+ const html = renderToStaticMarkup(<PillsActivas listado={listado} ctx={mockCtx()} />)
83
+
84
+ expect(html).toContain("Limpiar todo")
85
+ })
86
+
87
+ it("con seleccion:{} devuelve null", () => {
88
+ const listado = fixtureListado({ seleccion: {} })
89
+ const resultado = PillsActivas({ listado, ctx: mockCtx() })
90
+
91
+ expect(resultado).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe("Paginacion", () => {
96
+ it('modo "numerada" con totalPaginas:3 muestra 1,2,3 (via hrefPara) y aria-current en la pagina actual', () => {
97
+ const listado = fixtureListado({
98
+ paginacion: { pagina: 2, totalPaginas: 3, hrefPara: (p: number) => `?page=${p}` },
99
+ })
100
+ const html = renderToStaticMarkup(<Paginacion listado={listado} ctx={mockCtx()} />)
101
+
102
+ expect(html).toContain("<nav")
103
+ expect(html).toContain('aria-label="Paginación"')
104
+ expect(html).toContain('href="?page=1"')
105
+ expect(html).toContain('href="?page=2"')
106
+ expect(html).toContain('href="?page=3"')
107
+ expect(html).toMatch(/<a href="\?page=2"[^>]*aria-current="page"/)
108
+ })
109
+
110
+ it("muestra elipsis cuando totalPaginas > 7", () => {
111
+ const listado = fixtureListado({
112
+ paginacion: { pagina: 5, totalPaginas: 12, hrefPara: (p: number) => `?page=${p}` },
113
+ })
114
+ const html = renderToStaticMarkup(<Paginacion listado={listado} ctx={mockCtx()} />)
115
+
116
+ expect(html).toContain("…")
117
+ })
118
+
119
+ it('modo "load-more" muestra un boton "Cargar más"', () => {
120
+ const listado = fixtureListado()
121
+ const html = renderToStaticMarkup(<Paginacion listado={listado} ctx={mockCtx()} modo="load-more" />)
122
+
123
+ expect(html).toContain("Cargar más")
124
+ expect(html).toContain("<button")
125
+ })
126
+
127
+ it("default es modo numerada", () => {
128
+ const listado = fixtureListado({
129
+ paginacion: { pagina: 1, totalPaginas: 2, hrefPara: (p: number) => `?page=${p}` },
130
+ })
131
+ const html = renderToStaticMarkup(<Paginacion listado={listado} ctx={mockCtx()} />)
132
+
133
+ expect(html).toContain('aria-label="Paginación"')
134
+ })
135
+ })
136
+
137
+ describe("guard de estandar (cero <img>/<a> directos, sin <h1>)", () => {
138
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
139
+ expect(codigoFuente).not.toMatch(/<img[\s>]/)
140
+ expect(codigoFuente).not.toMatch(/<a[\s>]/)
141
+ })
142
+
143
+ it("ningun <h1>: son sub-componentes, el h1 de la categoria lo pone el layout PLP", () => {
144
+ expect(codigoFuente).not.toMatch(/<h1[\s>]/)
145
+ })
146
+ })