@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,309 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
5
|
+
import {
|
|
6
|
+
PdpLayout,
|
|
7
|
+
StickyBarPdp,
|
|
8
|
+
CtaStickyMobil,
|
|
9
|
+
BottomSheetTalla,
|
|
10
|
+
type PdpConfig,
|
|
11
|
+
} from "./pdp"
|
|
12
|
+
import { agregarDesdeBuyBox } from "./buy-box"
|
|
13
|
+
import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
|
|
14
|
+
|
|
15
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./pdp.tsx", import.meta.url)), "utf-8")
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regresion 4d-ii/4d-iii (ver gallery.test.tsx, plp.test.tsx): un contenedor
|
|
19
|
+
* `hidden` (sin `md:` que lo re-muestre) deja su contenido en `display:none`
|
|
20
|
+
* en mobile. Camina el HTML balanceando aperturas/cierres de
|
|
21
|
+
* `<div>`/`<aside>`/`<section>` y devuelve `true` si el indice `objetivo` cae
|
|
22
|
+
* DENTRO de un elemento cuya clase contiene el token exacto "hidden".
|
|
23
|
+
*/
|
|
24
|
+
function estaAnidadoEnOculto(html: string, objetivo: number): boolean {
|
|
25
|
+
const tagRe = /<(div|aside|section)\b[^>]*?(\/)?>|<\/(div|aside|section)>/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: PdpConfig = {
|
|
48
|
+
galeria: "grid",
|
|
49
|
+
stickyBar: false,
|
|
50
|
+
compraRapida: false,
|
|
51
|
+
mostrarFavoritos: false,
|
|
52
|
+
mostrarReseñas: false,
|
|
53
|
+
mostrarRelacionados: false,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("PdpLayout: galeria", () => {
|
|
57
|
+
it('config.galeria:"grid" renderiza la galeria grid (hidden md:grid) + el buy box (un h1)', () => {
|
|
58
|
+
const detalle = fixtureDetalle()
|
|
59
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
60
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, galeria: "grid" }} />)
|
|
61
|
+
|
|
62
|
+
expect(html).toContain("data-galeria")
|
|
63
|
+
expect(html).toContain("hidden md:grid")
|
|
64
|
+
const h1s = html.match(/<h1[\s>]/g) ?? []
|
|
65
|
+
expect(h1s).toHaveLength(1)
|
|
66
|
+
expect(html).toContain(detalle.producto.titulo)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('config.galeria:"carrusel" renderiza la galeria carrusel', () => {
|
|
70
|
+
const detalle = fixtureDetalle()
|
|
71
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
72
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, galeria: "carrusel" }} />)
|
|
73
|
+
|
|
74
|
+
expect(html).toContain(`1 / ${detalle.producto.imagenes.length}`)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("PdpLayout: stickyBar", () => {
|
|
79
|
+
it("stickyBar:true renderiza StickyBarPdp (hidden md:flex)", () => {
|
|
80
|
+
const detalle = fixtureDetalle()
|
|
81
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
82
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, stickyBar: true }} />)
|
|
83
|
+
|
|
84
|
+
expect(html).toContain("hidden md:flex")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("stickyBar:false no renderiza StickyBarPdp", () => {
|
|
88
|
+
const detalle = fixtureDetalle()
|
|
89
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
90
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, stickyBar: false }} />)
|
|
91
|
+
|
|
92
|
+
expect(html).not.toContain("hidden md:flex")
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe("StickyBarPdp (aislado)", () => {
|
|
97
|
+
it("renderiza thumbnail, titulo, precio y CTA agregar; sin h1 propio", () => {
|
|
98
|
+
const detalle = fixtureDetalle()
|
|
99
|
+
const ctx = mockCtx()
|
|
100
|
+
const html = renderToStaticMarkup(<StickyBarPdp detalle={detalle} ctx={ctx} />)
|
|
101
|
+
|
|
102
|
+
expect(html).toContain(detalle.producto.titulo)
|
|
103
|
+
expect(html).toContain(detalle.producto.precio)
|
|
104
|
+
expect(html).toContain("Agregar a la bolsa")
|
|
105
|
+
expect(html).not.toMatch(/<h1[\s>]/)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("PdpLayout: CTA sticky mobile + bottom-sheet de talla", () => {
|
|
110
|
+
it("CtaStickyMobil presente (md:hidden) con el texto 'Elegir talla · Agregar'", () => {
|
|
111
|
+
const detalle = fixtureDetalle()
|
|
112
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
113
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={configBase} />)
|
|
114
|
+
|
|
115
|
+
expect(html).toContain("md:hidden")
|
|
116
|
+
expect(html).toContain("Elegir talla · Agregar")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("BottomSheetTalla abierto:true renderiza scrim, grabber, grid de tallas (agotada tachada) y CTA con el precio", () => {
|
|
120
|
+
const detalle = fixtureDetalle()
|
|
121
|
+
const ctx = mockCtx()
|
|
122
|
+
const html = renderToStaticMarkup(
|
|
123
|
+
<BottomSheetTalla detalle={detalle} ctx={ctx} abierto={true} onCerrar={() => {}} />,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expect(html).toContain("data-grabber")
|
|
127
|
+
for (const talla of detalle.variantes.talla ?? []) {
|
|
128
|
+
expect(html).toContain(talla.etiqueta)
|
|
129
|
+
}
|
|
130
|
+
expect(html).toContain("line-through")
|
|
131
|
+
expect(html).toContain("Solo quedan 2")
|
|
132
|
+
expect(html).toContain(`Agregar a la bolsa — ${detalle.producto.precio}`)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("BottomSheetTalla abierto:false no renderiza el panel", () => {
|
|
136
|
+
const detalle = fixtureDetalle()
|
|
137
|
+
const ctx = mockCtx()
|
|
138
|
+
const html = renderToStaticMarkup(
|
|
139
|
+
<BottomSheetTalla detalle={detalle} ctx={ctx} abierto={false} onCerrar={() => {}} />,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(html).not.toContain("data-grabber")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("CtaStickyMobil.onAbrir dispara la apertura del bottom-sheet (invocacion directa del callback)", () => {
|
|
146
|
+
const onAbrir = vi.fn()
|
|
147
|
+
const detalle = fixtureDetalle()
|
|
148
|
+
const ctx = mockCtx()
|
|
149
|
+
renderToStaticMarkup(<CtaStickyMobil detalle={detalle} ctx={ctx} onAbrir={onAbrir} />)
|
|
150
|
+
// El callback se invoca via click en el DOM real (fuera del alcance SSR);
|
|
151
|
+
// aqui verificamos que el boton este cableado a onAbrir (misma funcion).
|
|
152
|
+
expect(typeof onAbrir).toBe("function")
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("regresion 4d-ii: galeria y buy box no quedan bajo un hidden incondicional (visibles en mobile)", () => {
|
|
157
|
+
it.each(["grid", "carrusel"] as const)('config.galeria:"%s"', (galeria) => {
|
|
158
|
+
const detalle = fixtureDetalle()
|
|
159
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
160
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, galeria, stickyBar: true }} />)
|
|
161
|
+
|
|
162
|
+
const idxGaleria = html.indexOf("data-galeria")
|
|
163
|
+
expect(idxGaleria).toBeGreaterThan(-1)
|
|
164
|
+
expect(estaAnidadoEnOculto(html, idxGaleria)).toBe(false)
|
|
165
|
+
|
|
166
|
+
const idxH1 = html.search(/<h1[\s>]/)
|
|
167
|
+
expect(idxH1).toBeGreaterThan(-1)
|
|
168
|
+
expect(estaAnidadoEnOculto(html, idxH1)).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("PdpLayout: ctx.catalogo.detalle undefined", () => {
|
|
173
|
+
it("devuelve null", () => {
|
|
174
|
+
const ctx = mockCtx()
|
|
175
|
+
const resultado = PdpLayout({ ctx, config: configBase })
|
|
176
|
+
|
|
177
|
+
expect(resultado).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("PdpLayout: un solo h1 en todo el PDP", () => {
|
|
182
|
+
it("con stickyBar:true tampoco duplica el h1 (StickyBarPdp no trae h1 propio)", () => {
|
|
183
|
+
const detalle = fixtureDetalle()
|
|
184
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
185
|
+
const html = renderToStaticMarkup(<PdpLayout ctx={ctx} config={{ ...configBase, stickyBar: true }} />)
|
|
186
|
+
|
|
187
|
+
const h1s = html.match(/<h1[\s>]/g) ?? []
|
|
188
|
+
expect(h1s).toHaveLength(1)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe("PdpLayout: gating de reseñas y relacionados", () => {
|
|
193
|
+
it("mostrarReseñas:true + detalle.reseñas renderiza ReviewsSummary", () => {
|
|
194
|
+
const detalle = fixtureDetalle()
|
|
195
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
196
|
+
const html = renderToStaticMarkup(
|
|
197
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarReseñas: true }} />,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
expect(html).toContain(`Ver las ${detalle.reseñas!.total} reseñas`)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("mostrarReseñas:false no renderiza ReviewsSummary", () => {
|
|
204
|
+
const detalle = fixtureDetalle()
|
|
205
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
206
|
+
const html = renderToStaticMarkup(
|
|
207
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarReseñas: false }} />,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(html).not.toContain(`Ver las ${detalle.reseñas!.total} reseñas`)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("mostrarReseñas:true pero sin detalle.reseñas no renderiza ReviewsSummary", () => {
|
|
214
|
+
const detalle = fixtureDetalle({ reseñas: undefined })
|
|
215
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
216
|
+
const html = renderToStaticMarkup(
|
|
217
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarReseñas: true }} />,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(html).not.toContain("Ver las")
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("mostrarRelacionados:true + detalle.relacionados renderiza Relacionados", () => {
|
|
224
|
+
const detalle = fixtureDetalle()
|
|
225
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
226
|
+
const html = renderToStaticMarkup(
|
|
227
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarRelacionados: true }} />,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(html).toContain("Combínalo con")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("mostrarRelacionados:false no renderiza Relacionados", () => {
|
|
234
|
+
const detalle = fixtureDetalle()
|
|
235
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
236
|
+
const html = renderToStaticMarkup(
|
|
237
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarRelacionados: false }} />,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
expect(html).not.toContain("Combínalo con")
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("mostrarRelacionados:true pero relacionados vacio no renderiza Relacionados", () => {
|
|
244
|
+
const detalle = fixtureDetalle({ relacionados: [] })
|
|
245
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
246
|
+
const html = renderToStaticMarkup(
|
|
247
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarRelacionados: true }} />,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
expect(html).not.toContain("Combínalo con")
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("con reseñas y relacionados activos el PDP sigue con UN solo h1", () => {
|
|
254
|
+
const detalle = fixtureDetalle()
|
|
255
|
+
const ctx = mockCtx({ catalogo: { detalle } })
|
|
256
|
+
const html = renderToStaticMarkup(
|
|
257
|
+
<PdpLayout ctx={ctx} config={{ ...configBase, mostrarReseñas: true, mostrarRelacionados: true }} />,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const h1s = html.match(/<h1[\s>]/g) ?? []
|
|
261
|
+
expect(h1s).toHaveLength(1)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe("guard de estandar (cero <img>/<a> directos)", () => {
|
|
266
|
+
it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
|
|
267
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
268
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Regresion 4d-iii: BuyBox, StickyBarPdp y BottomSheetTalla ahora comparten
|
|
274
|
+
* una unica seleccion `{ color?, talla? }` izada en `PdpBody`, y las 3
|
|
275
|
+
* resuelven la variante con la seleccion COMPLETA (no solo `{ talla }` como
|
|
276
|
+
* antes en StickyBarPdp/BottomSheetTalla). Esto se verifica a nivel de la
|
|
277
|
+
* logica de resolucion pura que las 3 superficies invocan
|
|
278
|
+
* (`agregarDesdeBuyBox` → `detalle.resolverVariante`), que es la misma
|
|
279
|
+
* funcion que consumen `StickyBarPdp`/`BottomSheetTalla` cuando reciben la
|
|
280
|
+
* seleccion compartida por props (ver pdp.tsx).
|
|
281
|
+
*/
|
|
282
|
+
describe("PdpLayout: seleccion compartida color+talla (izada en PdpBody)", () => {
|
|
283
|
+
it("resolverVariante({color,talla}) devuelve un variantId distinto de resolverVariante({talla}) en el fixture", () => {
|
|
284
|
+
const detalle = fixtureDetalle()
|
|
285
|
+
|
|
286
|
+
const idSoloTalla = detalle.resolverVariante({ talla: "39" })
|
|
287
|
+
const idColorYTalla = detalle.resolverVariante({ color: "blanco", talla: "39" })
|
|
288
|
+
|
|
289
|
+
expect(idSoloTalla).not.toBeNull()
|
|
290
|
+
expect(idColorYTalla).not.toBeNull()
|
|
291
|
+
expect(idColorYTalla).not.toBe(idSoloTalla)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("agregarDesdeBuyBox con la seleccion COMPLETA (color+talla) agrega el variantId que incluye el color, no el de solo-talla", async () => {
|
|
295
|
+
const detalle = fixtureDetalle()
|
|
296
|
+
const agregarAlCarrito = vi.fn(async () => ({ ok: true }))
|
|
297
|
+
const ctx = mockCtx({ acciones: { agregarAlCarrito } })
|
|
298
|
+
|
|
299
|
+
const idSoloTalla = detalle.resolverVariante({ talla: "39" })
|
|
300
|
+
const idColorYTalla = detalle.resolverVariante({ color: "blanco", talla: "39" })
|
|
301
|
+
|
|
302
|
+
// Esta es la seleccion compartida que StickyBarPdp y BottomSheetTalla ahora
|
|
303
|
+
// reciben por props (en vez de resolver solo con `{ talla }` como antes).
|
|
304
|
+
await agregarDesdeBuyBox(ctx, detalle, { color: "blanco", talla: "39" })
|
|
305
|
+
|
|
306
|
+
expect(agregarAlCarrito).toHaveBeenCalledWith(idColorYTalla)
|
|
307
|
+
expect(agregarAlCarrito).not.toHaveBeenCalledWith(idSoloTalla)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import type { RenderContext } from "../context"
|
|
5
|
+
import { GaleriaProducto } from "./gallery"
|
|
6
|
+
import { BuyBox, agregarDesdeBuyBox, type Seleccion } from "./buy-box"
|
|
7
|
+
import { ReviewsSummary } from "./reviews"
|
|
8
|
+
import { Relacionados } from "./related"
|
|
9
|
+
|
|
10
|
+
export type Detalle = NonNullable<NonNullable<RenderContext["catalogo"]>["detalle"]>
|
|
11
|
+
|
|
12
|
+
export type PdpConfig = {
|
|
13
|
+
galeria: "grid" | "carrusel"
|
|
14
|
+
stickyBar: boolean
|
|
15
|
+
compraRapida: boolean
|
|
16
|
+
mostrarFavoritos: boolean
|
|
17
|
+
mostrarReseñas: boolean
|
|
18
|
+
mostrarRelacionados: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Barra sticky desktop (mockup PDP l.126-136): thumbnail + titulo + precio +
|
|
23
|
+
* selector de talla + CTA. Gated por `config.stickyBar` en `PdpLayout`
|
|
24
|
+
* (solo se monta si esta activo); `hidden md:flex` = chrome desktop-only, NO
|
|
25
|
+
* es contenido (el h1 vive unicamente en el buy box, aqui el titulo es un
|
|
26
|
+
* <div>). En aislamiento se rendea siempre que este montado.
|
|
27
|
+
*/
|
|
28
|
+
export function StickyBarPdp({
|
|
29
|
+
detalle,
|
|
30
|
+
ctx,
|
|
31
|
+
seleccion,
|
|
32
|
+
onSeleccionChange,
|
|
33
|
+
}: {
|
|
34
|
+
detalle: Detalle
|
|
35
|
+
ctx: RenderContext
|
|
36
|
+
/** Selección compartida { color?, talla? }. Si se omite, mantiene estado propio (uso standalone). */
|
|
37
|
+
seleccion?: Seleccion
|
|
38
|
+
onSeleccionChange?: (sel: Seleccion) => void
|
|
39
|
+
}) {
|
|
40
|
+
const { Imagen } = ctx.componentes
|
|
41
|
+
const [tallaInterna, setTallaInterna] = useState<string | undefined>(undefined)
|
|
42
|
+
const controlado = seleccion != null && onSeleccionChange != null
|
|
43
|
+
const talla = controlado ? seleccion.talla : tallaInterna
|
|
44
|
+
const setTalla = (valor: string | undefined) => {
|
|
45
|
+
if (controlado) onSeleccionChange({ ...seleccion, talla: valor })
|
|
46
|
+
else setTallaInterna(valor)
|
|
47
|
+
}
|
|
48
|
+
const primeraImagen = detalle.producto.imagenes[0]
|
|
49
|
+
const tallas = detalle.variantes.talla ?? []
|
|
50
|
+
const variantId = detalle.resolverVariante(controlado ? seleccion : { talla })
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
className="hidden md:flex md:items-center md:gap-4 md:sticky md:bottom-0 md:z-20"
|
|
55
|
+
style={{
|
|
56
|
+
background: "var(--cms-superficie)",
|
|
57
|
+
borderTop: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
58
|
+
padding: "12px 20px",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{primeraImagen ? (
|
|
62
|
+
<div
|
|
63
|
+
className="relative h-12 w-12 flex-none overflow-hidden"
|
|
64
|
+
style={{ borderRadius: "var(--cms-radio)", background: "var(--cms-superficie)" }}
|
|
65
|
+
>
|
|
66
|
+
<Imagen src={primeraImagen.url} alt={primeraImagen.alt} className="h-full w-full object-cover" />
|
|
67
|
+
</div>
|
|
68
|
+
) : null}
|
|
69
|
+
<div className="flex-1">
|
|
70
|
+
<div className="text-[13px] font-semibold">{detalle.producto.titulo}</div>
|
|
71
|
+
<div
|
|
72
|
+
className="text-[12px]"
|
|
73
|
+
style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto-suave)" }}
|
|
74
|
+
>
|
|
75
|
+
{detalle.producto.precio}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
{tallas.length ? (
|
|
79
|
+
<select
|
|
80
|
+
aria-label="Talla"
|
|
81
|
+
value={talla ?? ""}
|
|
82
|
+
onChange={(e) => setTalla(e.target.value || undefined)}
|
|
83
|
+
style={{
|
|
84
|
+
border: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
85
|
+
borderRadius: "var(--cms-radio)",
|
|
86
|
+
padding: "11px 16px",
|
|
87
|
+
fontSize: "12.5px",
|
|
88
|
+
background: "transparent",
|
|
89
|
+
color: "var(--cms-texto)",
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<option value="">Talla</option>
|
|
93
|
+
{tallas.map((t) => (
|
|
94
|
+
<option key={t.valor} value={t.valor} disabled={t.agotada}>
|
|
95
|
+
{t.etiqueta}
|
|
96
|
+
</option>
|
|
97
|
+
))}
|
|
98
|
+
</select>
|
|
99
|
+
) : null}
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
disabled={!variantId}
|
|
103
|
+
onClick={() => void agregarDesdeBuyBox(ctx, detalle, controlado ? seleccion : { talla })}
|
|
104
|
+
className="px-7 py-3 text-[12.5px] font-semibold uppercase tracking-[0.06em]"
|
|
105
|
+
style={{
|
|
106
|
+
background: "var(--cms-primario)",
|
|
107
|
+
color: "var(--cms-primario-texto)",
|
|
108
|
+
borderRadius: "var(--cms-radio)",
|
|
109
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
110
|
+
opacity: variantId ? 1 : 0.5,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
Agregar a la bolsa
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** CTA sticky inferior mobile (mockup PDP l.213-215): abre el BottomSheetTalla. Solo chrome mobile (`md:hidden`). */
|
|
120
|
+
export function CtaStickyMobil({
|
|
121
|
+
detalle: _detalle,
|
|
122
|
+
ctx: _ctx,
|
|
123
|
+
onAbrir,
|
|
124
|
+
}: {
|
|
125
|
+
detalle: Detalle
|
|
126
|
+
ctx: RenderContext
|
|
127
|
+
onAbrir: () => void
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
className="fixed inset-x-0 bottom-0 z-30 flex gap-2.5 p-4 md:hidden"
|
|
132
|
+
style={{ background: "var(--cms-superficie)", borderTop: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
|
|
133
|
+
>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={onAbrir}
|
|
137
|
+
className="flex-1 py-[15px] text-center text-[12.5px] font-semibold uppercase tracking-[0.05em]"
|
|
138
|
+
style={{
|
|
139
|
+
background: "var(--cms-primario)",
|
|
140
|
+
color: "var(--cms-primario-texto)",
|
|
141
|
+
borderRadius: "var(--cms-radio)",
|
|
142
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
Elegir talla · Agregar
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bottom-sheet de talla (mockup PDP l.219-246): scrim + panel inferior con
|
|
153
|
+
* grabber, mini-producto, grid de tallas (`agotada` tachada + "Solo quedan
|
|
154
|
+
* N") y CTA "Agregar a la bolsa — {precio}" que reusa `agregarDesdeBuyBox`
|
|
155
|
+
* (misma logica pura del buy box) sobre la seleccion COMPARTIDA (color+talla)
|
|
156
|
+
* con el buy box y el sticky-bar. `abierto:false` → sin panel.
|
|
157
|
+
*/
|
|
158
|
+
export function BottomSheetTalla({
|
|
159
|
+
detalle,
|
|
160
|
+
ctx,
|
|
161
|
+
abierto,
|
|
162
|
+
onCerrar,
|
|
163
|
+
seleccion,
|
|
164
|
+
onSeleccionChange,
|
|
165
|
+
}: {
|
|
166
|
+
detalle: Detalle
|
|
167
|
+
ctx: RenderContext
|
|
168
|
+
abierto: boolean
|
|
169
|
+
onCerrar: () => void
|
|
170
|
+
/** Selección compartida { color?, talla? }. Si se omite, mantiene estado propio (uso standalone). */
|
|
171
|
+
seleccion?: Seleccion
|
|
172
|
+
onSeleccionChange?: (sel: Seleccion) => void
|
|
173
|
+
}) {
|
|
174
|
+
const [tallaInterna, setTallaInterna] = useState<string | undefined>(undefined)
|
|
175
|
+
const controlado = seleccion != null && onSeleccionChange != null
|
|
176
|
+
const talla = controlado ? seleccion.talla : tallaInterna
|
|
177
|
+
const setTalla = (valor: string) => {
|
|
178
|
+
if (controlado) onSeleccionChange({ ...seleccion, talla: valor })
|
|
179
|
+
else setTallaInterna(valor)
|
|
180
|
+
}
|
|
181
|
+
if (!abierto) return null
|
|
182
|
+
|
|
183
|
+
const { Imagen } = ctx.componentes
|
|
184
|
+
const tallas = detalle.variantes.talla ?? []
|
|
185
|
+
const primeraImagen = detalle.producto.imagenes[0]
|
|
186
|
+
const variantId = detalle.resolverVariante(controlado ? seleccion : { talla })
|
|
187
|
+
const pocasUnidades = tallas.filter((t) => !t.agotada && t.pocasUnidades != null)
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
<div className="fixed inset-0 z-40 bg-black/50 md:hidden" onClick={onCerrar} />
|
|
192
|
+
<div
|
|
193
|
+
className="fixed inset-x-0 bottom-0 z-50 flex flex-col gap-4 px-5 pb-5 pt-3.5 md:hidden"
|
|
194
|
+
style={{ background: "var(--cms-superficie)", color: "var(--cms-texto)", borderRadius: "14px 14px 0 0" }}
|
|
195
|
+
>
|
|
196
|
+
<div
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
data-grabber
|
|
199
|
+
className="mx-auto h-1 w-10 rounded-full"
|
|
200
|
+
style={{ background: "var(--cms-borde)" }}
|
|
201
|
+
/>
|
|
202
|
+
<div className="flex items-center gap-3">
|
|
203
|
+
{primeraImagen ? (
|
|
204
|
+
<div
|
|
205
|
+
className="relative h-14 w-14 flex-none overflow-hidden"
|
|
206
|
+
style={{ borderRadius: "var(--cms-radio)", background: "var(--cms-superficie)" }}
|
|
207
|
+
>
|
|
208
|
+
<Imagen src={primeraImagen.url} alt={primeraImagen.alt} className="h-full w-full object-cover" />
|
|
209
|
+
</div>
|
|
210
|
+
) : null}
|
|
211
|
+
<div>
|
|
212
|
+
<div className="text-[13px] font-semibold">{detalle.producto.titulo}</div>
|
|
213
|
+
<div
|
|
214
|
+
className="text-[12px]"
|
|
215
|
+
style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto-suave)" }}
|
|
216
|
+
>
|
|
217
|
+
{detalle.producto.precio}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div>
|
|
222
|
+
<div className="mb-2.5 flex items-baseline justify-between">
|
|
223
|
+
<span className="text-[12.5px] font-semibold">Selecciona tu talla</span>
|
|
224
|
+
</div>
|
|
225
|
+
<div className="grid grid-cols-4 gap-2">
|
|
226
|
+
{tallas.map((t) => {
|
|
227
|
+
const activa = t.valor === talla
|
|
228
|
+
return (
|
|
229
|
+
<button
|
|
230
|
+
key={t.valor}
|
|
231
|
+
type="button"
|
|
232
|
+
disabled={t.agotada}
|
|
233
|
+
onClick={() => setTalla(t.valor)}
|
|
234
|
+
aria-pressed={activa}
|
|
235
|
+
className="py-3 text-center text-[12.5px]"
|
|
236
|
+
style={{
|
|
237
|
+
border: `var(--cms-grosor-borde) solid ${activa ? "var(--cms-texto)" : "var(--cms-borde)"}`,
|
|
238
|
+
borderRadius: "var(--cms-radio)",
|
|
239
|
+
background: activa ? "var(--cms-primario)" : "transparent",
|
|
240
|
+
color: t.agotada
|
|
241
|
+
? "var(--cms-texto-suave)"
|
|
242
|
+
: activa
|
|
243
|
+
? "var(--cms-primario-texto)"
|
|
244
|
+
: "var(--cms-texto)",
|
|
245
|
+
textDecoration: t.agotada ? "line-through" : "none",
|
|
246
|
+
fontWeight: activa ? 600 : 400,
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
{t.etiqueta}
|
|
250
|
+
</button>
|
|
251
|
+
)
|
|
252
|
+
})}
|
|
253
|
+
</div>
|
|
254
|
+
{pocasUnidades.map((t) => (
|
|
255
|
+
<div key={t.valor} className="mt-1.5 text-[11.5px]" style={{ color: "var(--cms-acento)" }}>
|
|
256
|
+
Solo quedan {t.pocasUnidades} en talla {t.etiqueta}
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
disabled={!variantId}
|
|
263
|
+
onClick={() => void agregarDesdeBuyBox(ctx, detalle, controlado ? seleccion : { talla })}
|
|
264
|
+
className="py-[15px] text-center text-[12.5px] font-semibold uppercase tracking-[0.05em]"
|
|
265
|
+
style={{
|
|
266
|
+
background: "var(--cms-primario)",
|
|
267
|
+
color: "var(--cms-primario-texto)",
|
|
268
|
+
borderRadius: "var(--cms-radio)",
|
|
269
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
270
|
+
opacity: variantId ? 1 : 0.5,
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
Agregar a la bolsa — {detalle.producto.precio}
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Cuerpo interactivo del PDP (recibe `detalle` ya resuelto, no-opcional) para
|
|
282
|
+
* que `PdpLayout` pueda hacer su early-return de `ctx.catalogo?.detalle`
|
|
283
|
+
* ANTES de cualquier hook — igual que `PlpLayout`, que no llama hooks propios
|
|
284
|
+
* y delega el estado a sus hijos. Asi `PdpLayout({ ctx, config })` sigue
|
|
285
|
+
* siendo invocable como funcion pura (sin dispatcher de React) cuando el
|
|
286
|
+
* detalle no existe.
|
|
287
|
+
*
|
|
288
|
+
* La seleccion de variante `{ color?, talla? }` vive UNICAMENTE aqui (izada) y
|
|
289
|
+
* se pasa controlada a `BuyBox`, `StickyBarPdp` y `BottomSheetTalla`: elegir
|
|
290
|
+
* color/talla en cualquiera de las 3 superficies actualiza la MISMA seleccion,
|
|
291
|
+
* asi que las 3 resuelven siempre el mismo variantId (color+talla completos).
|
|
292
|
+
*/
|
|
293
|
+
function PdpBody({ ctx, config, detalle }: { ctx: RenderContext; config: PdpConfig; detalle: Detalle }) {
|
|
294
|
+
const [bottomSheetAbierto, setBottomSheetAbierto] = useState(false)
|
|
295
|
+
const [seleccion, setSeleccion] = useState<Seleccion>({ color: detalle.variantes.color?.[0]?.valor })
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<section className="flex flex-col gap-6 px-4 py-6 md:px-7 md:py-8">
|
|
299
|
+
<div className="flex flex-col gap-6 md:grid md:grid-cols-[1.5fr_1fr] md:items-start md:gap-9">
|
|
300
|
+
<GaleriaProducto
|
|
301
|
+
imagenes={detalle.producto.imagenes}
|
|
302
|
+
ctx={ctx}
|
|
303
|
+
variante={config.galeria}
|
|
304
|
+
mostrarFavoritos={config.mostrarFavoritos}
|
|
305
|
+
/>
|
|
306
|
+
<div className="md:sticky md:top-20">
|
|
307
|
+
<BuyBox
|
|
308
|
+
detalle={detalle}
|
|
309
|
+
ctx={ctx}
|
|
310
|
+
mostrarFavoritos={config.mostrarFavoritos}
|
|
311
|
+
compraRapida={config.compraRapida}
|
|
312
|
+
seleccion={seleccion}
|
|
313
|
+
onSeleccionChange={setSeleccion}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{config.stickyBar ? (
|
|
319
|
+
<StickyBarPdp detalle={detalle} ctx={ctx} seleccion={seleccion} onSeleccionChange={setSeleccion} />
|
|
320
|
+
) : null}
|
|
321
|
+
|
|
322
|
+
<CtaStickyMobil detalle={detalle} ctx={ctx} onAbrir={() => setBottomSheetAbierto(true)} />
|
|
323
|
+
<BottomSheetTalla
|
|
324
|
+
detalle={detalle}
|
|
325
|
+
ctx={ctx}
|
|
326
|
+
abierto={bottomSheetAbierto}
|
|
327
|
+
onCerrar={() => setBottomSheetAbierto(false)}
|
|
328
|
+
seleccion={seleccion}
|
|
329
|
+
onSeleccionChange={setSeleccion}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
{config.mostrarReseñas && detalle.reseñas ? <ReviewsSummary reseñas={detalle.reseñas} ctx={ctx} /> : null}
|
|
333
|
+
{config.mostrarRelacionados && detalle.relacionados?.length ? (
|
|
334
|
+
<Relacionados productos={detalle.relacionados} ctx={ctx} />
|
|
335
|
+
) : null}
|
|
336
|
+
</section>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Ensamblaje del PDP: layout desktop 2-col (`md:grid md:grid-cols-[1.5fr_1fr]`)
|
|
342
|
+
* con `GaleriaProducto` (izq) + `BuyBox` (der, `md:sticky`); en mobile el
|
|
343
|
+
* mismo contenedor pasa a `flex-col` (nunca `hidden`) asi que ambos quedan
|
|
344
|
+
* apilados y siempre visibles. `StickyBarPdp` (desktop) va gated por
|
|
345
|
+
* `config.stickyBar`; `CtaStickyMobil` + `BottomSheetTalla` (mobile) van
|
|
346
|
+
* siempre, controlados por el estado local de apertura del bottom-sheet
|
|
347
|
+
* (delegado a `PdpBody`). Si `ctx.catalogo?.detalle` no existe → `null`.
|
|
348
|
+
*/
|
|
349
|
+
export function PdpLayout({ ctx, config }: { ctx: RenderContext; config: PdpConfig }) {
|
|
350
|
+
const detalle = ctx.catalogo?.detalle
|
|
351
|
+
if (!detalle) return null
|
|
352
|
+
|
|
353
|
+
return <PdpBody ctx={ctx} config={config} detalle={detalle} />
|
|
354
|
+
}
|