@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,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
|
+
}
|