@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.
- package/docs/design/README.md +22 -0
- package/docs/design/kinetic-street-concept.html +558 -0
- package/package.json +45 -0
- package/src/blocks/_shared.tsx +174 -0
- package/src/blocks/carrusel.tsx +109 -0
- package/src/blocks/category-tiles.test.tsx +59 -0
- package/src/blocks/category-tiles.tsx +70 -0
- package/src/blocks/editorial-banner.test.tsx +96 -0
- package/src/blocks/editorial-banner.tsx +205 -0
- package/src/blocks/faq.test.tsx +68 -0
- package/src/blocks/faq.tsx +122 -0
- package/src/blocks/featured-collection.test.tsx +80 -0
- package/src/blocks/featured-collection.tsx +97 -0
- package/src/blocks/features.test.tsx +71 -0
- package/src/blocks/features.tsx +159 -0
- package/src/blocks/hero.test.tsx +75 -0
- package/src/blocks/hero.tsx +290 -0
- package/src/blocks/index.tsx +35 -0
- package/src/blocks/lookbook.test.tsx +80 -0
- package/src/blocks/lookbook.tsx +117 -0
- package/src/blocks/marquee.test.tsx +54 -0
- package/src/blocks/marquee.tsx +114 -0
- package/src/blocks/newsletter.test.tsx +60 -0
- package/src/blocks/newsletter.tsx +105 -0
- package/src/blocks/product-grid.test.tsx +160 -0
- package/src/blocks/product-grid.tsx +76 -0
- package/src/blocks/promo-countdown.tsx +75 -0
- package/src/blocks/promo.test.tsx +71 -0
- package/src/blocks/promo.tsx +120 -0
- package/src/blocks/rich-text.test.tsx +67 -0
- package/src/blocks/rich-text.tsx +75 -0
- package/src/blocks/spacer.test.tsx +54 -0
- package/src/blocks/spacer.tsx +56 -0
- package/src/blocks/testimonials.test.tsx +74 -0
- package/src/blocks/testimonials.tsx +104 -0
- package/src/blocks/video.test.tsx +159 -0
- package/src/blocks/video.tsx +226 -0
- package/src/catalog/buy-box.test.tsx +225 -0
- package/src/catalog/buy-box.tsx +353 -0
- package/src/catalog/config.test.ts +115 -0
- package/src/catalog/config.ts +167 -0
- package/src/catalog/filter-controls.test.tsx +149 -0
- package/src/catalog/filter-controls.tsx +187 -0
- package/src/catalog/gallery.test.tsx +201 -0
- package/src/catalog/gallery.tsx +212 -0
- package/src/catalog/lightbox.test.tsx +95 -0
- package/src/catalog/lightbox.tsx +184 -0
- package/src/catalog/pdp.test.tsx +309 -0
- package/src/catalog/pdp.tsx +354 -0
- package/src/catalog/plp-arreglos.test.tsx +235 -0
- package/src/catalog/plp-inline.tsx +54 -0
- package/src/catalog/plp-overlay.tsx +129 -0
- package/src/catalog/plp-parts.test.tsx +146 -0
- package/src/catalog/plp-parts.tsx +292 -0
- package/src/catalog/plp-pills.tsx +44 -0
- package/src/catalog/plp-sidebar.tsx +47 -0
- package/src/catalog/plp.test.tsx +319 -0
- package/src/catalog/plp.tsx +259 -0
- package/src/catalog/product-card.test.tsx +221 -0
- package/src/catalog/product-card.tsx +411 -0
- package/src/catalog/registry.tsx +16 -0
- package/src/catalog/related.test.tsx +52 -0
- package/src/catalog/related.tsx +40 -0
- package/src/catalog/reviews.test.tsx +56 -0
- package/src/catalog/reviews.tsx +116 -0
- package/src/catalog/skeletons.test.tsx +61 -0
- package/src/catalog/skeletons.tsx +103 -0
- package/src/chrome/announcement-bar.test.tsx +86 -0
- package/src/chrome/announcement-bar.tsx +173 -0
- package/src/chrome/drawer-movil.test.tsx +113 -0
- package/src/chrome/drawer-movil.tsx +342 -0
- package/src/chrome/footer.test.tsx +133 -0
- package/src/chrome/footer.tsx +603 -0
- package/src/chrome/header.test.tsx +165 -0
- package/src/chrome/header.tsx +592 -0
- package/src/chrome/index.ts +26 -0
- package/src/chrome/mega-menu.test.tsx +161 -0
- package/src/chrome/mega-menu.tsx +317 -0
- package/src/chrome/mini-cart.test.tsx +111 -0
- package/src/chrome/mini-cart.tsx +320 -0
- package/src/content/home.ts +19 -0
- package/src/content/seed.test.ts +33 -0
- package/src/content/seed.ts +28 -0
- package/src/context.ts +104 -0
- package/src/icons/icons.test.tsx +118 -0
- package/src/icons/index.ts +136 -0
- package/src/icons/payment-icons.tsx +241 -0
- package/src/icons/social-icons.tsx +108 -0
- package/src/icons/svg-base.tsx +68 -0
- package/src/index.ts +49 -0
- package/src/puck-config.tsx +404 -0
- package/src/smoke.test.ts +8 -0
- package/src/template.test.tsx +88 -0
- package/src/test/mock-ctx.test.tsx +77 -0
- package/src/test/mock-ctx.tsx +236 -0
- package/src/theme-defaults.test.ts +9 -0
- package/src/theme.test.ts +82 -0
- package/src/theme.ts +195 -0
- 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
|
+
})
|