@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,201 @@
|
|
|
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 { GaleriaProducto } from "./gallery"
|
|
6
|
+
import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./gallery.tsx", import.meta.url)), "utf-8")
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regresion 4d-ii (ver plp.test.tsx): un contenedor `hidden` (sin `md:` que lo
|
|
12
|
+
* re-muestre) deja su contenido en `display:none` en mobile. Camina el HTML
|
|
13
|
+
* balanceando aperturas/cierres de `<div>`/`<aside>` y devuelve `true` si el
|
|
14
|
+
* indice `objetivo` cae DENTRO de un elemento cuya clase contiene el token
|
|
15
|
+
* exacto "hidden".
|
|
16
|
+
*/
|
|
17
|
+
function estaAnidadoEnOculto(html: string, objetivo: number): boolean {
|
|
18
|
+
const tagRe = /<(div|aside)\b[^>]*?(\/)?>|<\/(div|aside)>/g
|
|
19
|
+
const pila: { oculto: boolean; inicio: number }[] = []
|
|
20
|
+
let match: RegExpExecArray | null
|
|
21
|
+
while ((match = tagRe.exec(html))) {
|
|
22
|
+
const esCierre = match[0].startsWith("</")
|
|
23
|
+
if (esCierre) {
|
|
24
|
+
const marco = pila.pop()
|
|
25
|
+
if (marco?.oculto && objetivo > marco.inicio && objetivo < match.index) {
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
const esAutoCerrado = match[2] === "/"
|
|
31
|
+
const claseMatch = match[0].match(/\bclass="([^"]*)"/)
|
|
32
|
+
const esOculto = claseMatch ? claseMatch[1].split(/\s+/).includes("hidden") : false
|
|
33
|
+
if (!esAutoCerrado) {
|
|
34
|
+
pila.push({ oculto: esOculto, inicio: match.index })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tagsImg(html: string): string[] {
|
|
41
|
+
return html.match(/<img[^>]*>/g) ?? []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("GaleriaProducto variante grid", () => {
|
|
45
|
+
it("renderiza las imagenes (via ctx.componentes.Imagen) con su alt", () => {
|
|
46
|
+
const detalle = fixtureDetalle()
|
|
47
|
+
const html = renderToStaticMarkup(
|
|
48
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="grid" />,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
for (const img of detalle.producto.imagenes) {
|
|
52
|
+
expect(html).toContain(`alt="${img.alt}"`)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("la primera imagen (LCP) lleva prioridad; las demas no", () => {
|
|
57
|
+
const detalle = fixtureDetalle()
|
|
58
|
+
const html = renderToStaticMarkup(
|
|
59
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="grid" />,
|
|
60
|
+
)
|
|
61
|
+
const tags = tagsImg(html)
|
|
62
|
+
const [primera, ...resto] = detalle.producto.imagenes
|
|
63
|
+
|
|
64
|
+
const tagsPrimera = tags.filter((t) => t.includes(`src="${primera.url}"`))
|
|
65
|
+
expect(tagsPrimera.length).toBeGreaterThan(0)
|
|
66
|
+
for (const t of tagsPrimera) expect(t).toContain('data-prioridad="true"')
|
|
67
|
+
|
|
68
|
+
for (const img of resto) {
|
|
69
|
+
const tagsOtra = tags.filter((t) => t.includes(`src="${img.url}"`))
|
|
70
|
+
for (const t of tagsOtra) expect(t).not.toContain("data-prioridad")
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("layout desktop 2x2 (hidden md:grid)", () => {
|
|
75
|
+
const detalle = fixtureDetalle()
|
|
76
|
+
const html = renderToStaticMarkup(
|
|
77
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="grid" />,
|
|
78
|
+
)
|
|
79
|
+
expect(html).toContain("hidden md:grid")
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("GaleriaProducto variante carrusel", () => {
|
|
84
|
+
it("renderiza imagen principal + thumbnails (hidden md:...)", () => {
|
|
85
|
+
const detalle = fixtureDetalle()
|
|
86
|
+
const html = renderToStaticMarkup(
|
|
87
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="carrusel" />,
|
|
88
|
+
)
|
|
89
|
+
expect(html).toMatch(/hidden md:/)
|
|
90
|
+
for (const img of detalle.producto.imagenes) {
|
|
91
|
+
expect(html).toContain(`alt="${img.alt}"`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('renderiza el contador "1 / N"', () => {
|
|
96
|
+
const detalle = fixtureDetalle()
|
|
97
|
+
const html = renderToStaticMarkup(
|
|
98
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="carrusel" />,
|
|
99
|
+
)
|
|
100
|
+
expect(html).toContain(`1 / ${detalle.producto.imagenes.length}`)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("GaleriaProducto: galeria mobile swipe (md:hidden) siempre presente", () => {
|
|
105
|
+
it.each(["grid", "carrusel"] as const)(
|
|
106
|
+
'variante:"%s": la galeria mobile swipe renderiza TODAS las imagenes con dots',
|
|
107
|
+
(variante) => {
|
|
108
|
+
const detalle = fixtureDetalle()
|
|
109
|
+
const html = renderToStaticMarkup(
|
|
110
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante={variante} />,
|
|
111
|
+
)
|
|
112
|
+
expect(html).toContain("md:hidden")
|
|
113
|
+
|
|
114
|
+
const dots = html.match(/data-dot/g) ?? []
|
|
115
|
+
expect(dots).toHaveLength(detalle.producto.imagenes.length)
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
it("mostrarFavoritos:false no renderiza el boton de favoritos en la galeria mobile", () => {
|
|
120
|
+
const detalle = fixtureDetalle()
|
|
121
|
+
const html = renderToStaticMarkup(
|
|
122
|
+
<GaleriaProducto
|
|
123
|
+
imagenes={detalle.producto.imagenes}
|
|
124
|
+
ctx={mockCtx()}
|
|
125
|
+
variante="grid"
|
|
126
|
+
mostrarFavoritos={false}
|
|
127
|
+
/>,
|
|
128
|
+
)
|
|
129
|
+
expect(html).not.toContain("♡")
|
|
130
|
+
expect(html).not.toContain("♥")
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("mostrarFavoritos:true renderiza el boton de favoritos en la galeria mobile", () => {
|
|
134
|
+
const detalle = fixtureDetalle()
|
|
135
|
+
const html = renderToStaticMarkup(
|
|
136
|
+
<GaleriaProducto
|
|
137
|
+
imagenes={detalle.producto.imagenes}
|
|
138
|
+
ctx={mockCtx()}
|
|
139
|
+
variante="grid"
|
|
140
|
+
mostrarFavoritos={true}
|
|
141
|
+
/>,
|
|
142
|
+
)
|
|
143
|
+
expect(html).toContain("♡")
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("regresion 4d-ii: el contenido de la galeria (data-dot, imagenes mobile) no queda bajo un hidden incondicional", () => {
|
|
148
|
+
it.each(["grid", "carrusel"] as const)('variante:"%s": verifica TODOS los data-dot mobile', (variante) => {
|
|
149
|
+
const detalle = fixtureDetalle()
|
|
150
|
+
const html = renderToStaticMarkup(
|
|
151
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante={variante} />,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// Verifica que data-galeria no esté bajo hidden incondicional
|
|
155
|
+
const idxGaleria = html.indexOf("data-galeria")
|
|
156
|
+
expect(idxGaleria).toBeGreaterThan(-1)
|
|
157
|
+
expect(estaAnidadoEnOculto(html, idxGaleria)).toBe(false)
|
|
158
|
+
|
|
159
|
+
// Encuentra TODOS los data-dot en el HTML (galeria mobile swipe)
|
|
160
|
+
const dotRegex = /data-dot/g
|
|
161
|
+
const dotMatches = Array.from(html.matchAll(dotRegex))
|
|
162
|
+
expect(dotMatches.length).toBe(detalle.producto.imagenes.length)
|
|
163
|
+
|
|
164
|
+
// Verifica que NINGUNO de los data-dot esté anidado en un hidden incondicional
|
|
165
|
+
for (const match of dotMatches) {
|
|
166
|
+
const idxDot = match.index ?? -1
|
|
167
|
+
expect(idxDot).toBeGreaterThan(-1)
|
|
168
|
+
expect(estaAnidadoEnOculto(html, idxDot)).toBe(false)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe("zoom / lightbox: disparadores sobre las imagenes", () => {
|
|
174
|
+
it.each(["grid", "carrusel"] as const)(
|
|
175
|
+
'variante:"%s": expone botones "Ampliar imagen" (data-zoom) para abrir el lightbox',
|
|
176
|
+
(variante) => {
|
|
177
|
+
const detalle = fixtureDetalle()
|
|
178
|
+
const html = renderToStaticMarkup(
|
|
179
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante={variante} />,
|
|
180
|
+
)
|
|
181
|
+
expect(html).toContain("data-zoom")
|
|
182
|
+
expect(html).toMatch(/aria-label="Ampliar imagen:/)
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
it("el lightbox NO se renderiza hasta que se abre (cerrado por defecto)", () => {
|
|
187
|
+
const detalle = fixtureDetalle()
|
|
188
|
+
const html = renderToStaticMarkup(
|
|
189
|
+
<GaleriaProducto imagenes={detalle.producto.imagenes} ctx={mockCtx()} variante="grid" />,
|
|
190
|
+
)
|
|
191
|
+
expect(html).not.toContain("data-lightbox")
|
|
192
|
+
expect(html).not.toContain('aria-label="Imagen ampliada"')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe("guard de estandar (cero <img>/<a> directos)", () => {
|
|
197
|
+
it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
|
|
198
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
199
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import type { RenderContext } from "../context"
|
|
5
|
+
import { Lightbox } from "./lightbox"
|
|
6
|
+
|
|
7
|
+
export type ImagenGaleria = { url: string; alt: string }
|
|
8
|
+
export type GaleriaVariante = "grid" | "carrusel"
|
|
9
|
+
|
|
10
|
+
/** Callback que abre el lightbox en la imagen `indice`. */
|
|
11
|
+
type AbrirZoom = (indice: number) => void
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Botón-overlay transparente que cubre una imagen (`absolute inset-0`) para
|
|
15
|
+
* abrir el zoom/lightbox al hacer click. `z-[1]` para quedar sobre la imagen
|
|
16
|
+
* pero debajo del chrome (nav ‹ ›, badges, favoritos) que va en `z-10`.
|
|
17
|
+
*/
|
|
18
|
+
function DisparadorZoom({ indice, alt, onAbrir }: { indice: number; alt: string; onAbrir?: AbrirZoom }) {
|
|
19
|
+
if (!onAbrir) return null
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
data-zoom
|
|
24
|
+
onClick={() => onAbrir(indice)}
|
|
25
|
+
aria-label={`Ampliar imagen: ${alt}`}
|
|
26
|
+
className="absolute inset-0 z-[1] h-full w-full cursor-zoom-in bg-transparent p-0"
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Galeria desktop en grilla 2x2 (mockup PDP grid l.35-40). CLS 0: aspect-ratio reservado. */
|
|
32
|
+
function GaleriaGrid({ imagenes, ctx, onAbrirZoom }: { imagenes: ImagenGaleria[]; ctx: RenderContext; onAbrirZoom?: AbrirZoom }) {
|
|
33
|
+
const { Imagen } = ctx.componentes
|
|
34
|
+
return (
|
|
35
|
+
<div className="hidden md:grid md:grid-cols-2 md:gap-3">
|
|
36
|
+
{imagenes.map((img, i) => (
|
|
37
|
+
<div
|
|
38
|
+
key={img.url}
|
|
39
|
+
className="relative overflow-hidden"
|
|
40
|
+
style={{ aspectRatio: "1 / 1", background: "var(--cms-superficie)", borderRadius: "var(--cms-radio)" }}
|
|
41
|
+
>
|
|
42
|
+
<Imagen src={img.url} alt={img.alt} className="h-full w-full object-cover" prioridad={i === 0} />
|
|
43
|
+
<DisparadorZoom indice={i} alt={img.alt} onAbrir={onAbrirZoom} />
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Galeria desktop tipo carrusel: imagen principal + thumbnails verticales (mockup PDP carrusel l.99-110). */
|
|
51
|
+
function GaleriaCarrusel({ imagenes, ctx, onAbrirZoom }: { imagenes: ImagenGaleria[]; ctx: RenderContext; onAbrirZoom?: AbrirZoom }) {
|
|
52
|
+
const { Imagen } = ctx.componentes
|
|
53
|
+
const [activo, setActivo] = useState(0)
|
|
54
|
+
const total = imagenes.length
|
|
55
|
+
const actual = imagenes[activo]
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="hidden md:grid md:grid-cols-[76px_1fr] md:gap-5 md:items-start">
|
|
59
|
+
<div className="flex flex-col gap-2.5">
|
|
60
|
+
{imagenes.map((img, i) => (
|
|
61
|
+
<button
|
|
62
|
+
key={img.url}
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={() => setActivo(i)}
|
|
65
|
+
aria-label={`Ver imagen ${i + 1}`}
|
|
66
|
+
aria-pressed={i === activo}
|
|
67
|
+
className="relative overflow-hidden p-0"
|
|
68
|
+
style={{
|
|
69
|
+
aspectRatio: "1 / 1",
|
|
70
|
+
borderRadius: "var(--cms-radio)",
|
|
71
|
+
outline: i === activo ? "2px solid var(--cms-texto)" : "none",
|
|
72
|
+
outlineOffset: "2px",
|
|
73
|
+
border: "none",
|
|
74
|
+
background: "transparent",
|
|
75
|
+
opacity: i === activo ? 1 : 0.6,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Imagen src={img.url} alt={img.alt} className="h-full w-full object-cover" />
|
|
79
|
+
</button>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
<div
|
|
83
|
+
className="relative overflow-hidden"
|
|
84
|
+
style={{ aspectRatio: "1200 / 1500", background: "var(--cms-superficie)", borderRadius: "var(--cms-radio)" }}
|
|
85
|
+
>
|
|
86
|
+
<Imagen src={actual.url} alt={actual.alt} className="h-full w-full object-cover" prioridad={activo === 0} />
|
|
87
|
+
<DisparadorZoom indice={activo} alt={actual.alt} onAbrir={onAbrirZoom} />
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={() => setActivo((a) => (a - 1 + total) % total)}
|
|
91
|
+
aria-label="Imagen anterior"
|
|
92
|
+
className="absolute left-3.5 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full"
|
|
93
|
+
style={{ background: "var(--cms-superficie)", border: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
|
|
94
|
+
>
|
|
95
|
+
‹
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setActivo((a) => (a + 1) % total)}
|
|
100
|
+
aria-label="Imagen siguiente"
|
|
101
|
+
className="absolute right-3.5 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full"
|
|
102
|
+
style={{ background: "var(--cms-superficie)", border: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
|
|
103
|
+
>
|
|
104
|
+
›
|
|
105
|
+
</button>
|
|
106
|
+
<span
|
|
107
|
+
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full px-2.5 py-1 text-[10px] font-medium"
|
|
108
|
+
style={{
|
|
109
|
+
background: "var(--cms-superficie)",
|
|
110
|
+
border: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
111
|
+
fontFamily: "var(--cms-fuente-mono)",
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{activo + 1} / {total}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Galeria mobile swipe edge-to-edge con dots + favoritos opcional (mockup PDP mobile l.191-194). */
|
|
122
|
+
function GaleriaSwipeMobil({
|
|
123
|
+
imagenes,
|
|
124
|
+
ctx,
|
|
125
|
+
mostrarFavoritos = false,
|
|
126
|
+
onAbrirZoom,
|
|
127
|
+
}: {
|
|
128
|
+
imagenes: ImagenGaleria[]
|
|
129
|
+
ctx: RenderContext
|
|
130
|
+
mostrarFavoritos?: boolean
|
|
131
|
+
onAbrirZoom?: AbrirZoom
|
|
132
|
+
}) {
|
|
133
|
+
const { Imagen } = ctx.componentes
|
|
134
|
+
const [favorito, setFavorito] = useState(false)
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="relative md:hidden">
|
|
138
|
+
<div className="flex snap-x snap-mandatory overflow-x-auto" style={{ scrollbarWidth: "none" }}>
|
|
139
|
+
{imagenes.map((img, i) => (
|
|
140
|
+
<div
|
|
141
|
+
key={img.url}
|
|
142
|
+
className="relative w-full flex-none snap-center overflow-hidden"
|
|
143
|
+
style={{ aspectRatio: "1 / 1.25", background: "var(--cms-superficie)" }}
|
|
144
|
+
>
|
|
145
|
+
<Imagen src={img.url} alt={img.alt} className="h-full w-full object-cover" prioridad={i === 0} />
|
|
146
|
+
<DisparadorZoom indice={i} alt={img.alt} onAbrir={onAbrirZoom} />
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
<div className="absolute inset-x-0 bottom-2.5 flex items-center justify-center gap-1.5" aria-hidden="true">
|
|
151
|
+
{imagenes.map((img, i) => (
|
|
152
|
+
<span
|
|
153
|
+
key={img.url}
|
|
154
|
+
data-dot
|
|
155
|
+
className="h-1.5 w-1.5 rounded-full"
|
|
156
|
+
style={{ background: "var(--cms-texto)", opacity: i === 0 ? 1 : 0.3 }}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
{mostrarFavoritos ? (
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => setFavorito((v) => !v)}
|
|
164
|
+
aria-pressed={favorito}
|
|
165
|
+
aria-label="favoritos"
|
|
166
|
+
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full text-[16px]"
|
|
167
|
+
style={{ background: "var(--cms-superficie)", border: "var(--cms-grosor-borde) solid var(--cms-borde)" }}
|
|
168
|
+
>
|
|
169
|
+
{favorito ? "♥" : "♡"}
|
|
170
|
+
</button>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const DESKTOP_POR_VARIANTE: Record<GaleriaVariante, typeof GaleriaGrid> = {
|
|
177
|
+
grid: GaleriaGrid,
|
|
178
|
+
carrusel: GaleriaCarrusel,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Galeria del PDP: despacha por `variante` en desktop (`hidden md:...`) y
|
|
183
|
+
* SIEMPRE renderiza la galeria mobile swipe (`md:hidden`) — lección 4d-ii: el
|
|
184
|
+
* contenido (las imagenes) nunca queda anidado en un `hidden` incondicional,
|
|
185
|
+
* solo el chrome desktop/mobile-only usa `hidden`/`md:hidden` para dispatchear
|
|
186
|
+
* entre las dos presentaciones, que coexisten SIEMPRE en el DOM.
|
|
187
|
+
*/
|
|
188
|
+
export function GaleriaProducto({
|
|
189
|
+
imagenes,
|
|
190
|
+
ctx,
|
|
191
|
+
variante,
|
|
192
|
+
mostrarFavoritos = false,
|
|
193
|
+
}: {
|
|
194
|
+
imagenes: ImagenGaleria[]
|
|
195
|
+
ctx: RenderContext
|
|
196
|
+
variante: GaleriaVariante
|
|
197
|
+
mostrarFavoritos?: boolean
|
|
198
|
+
}) {
|
|
199
|
+
const Desktop = DESKTOP_POR_VARIANTE[variante] ?? GaleriaGrid
|
|
200
|
+
// `null` = lightbox cerrado; un número = índice de la imagen ampliada.
|
|
201
|
+
const [zoom, setZoom] = useState<number | null>(null)
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div data-galeria className="flex flex-col">
|
|
205
|
+
<Desktop imagenes={imagenes} ctx={ctx} onAbrirZoom={setZoom} />
|
|
206
|
+
<GaleriaSwipeMobil imagenes={imagenes} ctx={ctx} mostrarFavoritos={mostrarFavoritos} onAbrirZoom={setZoom} />
|
|
207
|
+
{zoom !== null ? (
|
|
208
|
+
<Lightbox imagenes={imagenes} ctx={ctx} indiceInicial={zoom} onCerrar={() => setZoom(null)} />
|
|
209
|
+
) : null}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { Lightbox, siguienteIndice, anteriorIndice } from "./lightbox"
|
|
6
|
+
import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./lightbox.tsx", import.meta.url)), "utf-8")
|
|
9
|
+
|
|
10
|
+
describe("siguienteIndice / anteriorIndice (wrap-around)", () => {
|
|
11
|
+
it("siguienteIndice avanza y da la vuelta al final", () => {
|
|
12
|
+
expect(siguienteIndice(0, 4)).toBe(1)
|
|
13
|
+
expect(siguienteIndice(3, 4)).toBe(0)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("anteriorIndice retrocede y da la vuelta al inicio", () => {
|
|
17
|
+
expect(anteriorIndice(1, 4)).toBe(0)
|
|
18
|
+
expect(anteriorIndice(0, 4)).toBe(3)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("total 0 no revienta (devuelve 0)", () => {
|
|
22
|
+
expect(siguienteIndice(0, 0)).toBe(0)
|
|
23
|
+
expect(anteriorIndice(0, 0)).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("Lightbox markup + aria", () => {
|
|
28
|
+
it("renderiza un dialog modal con aria-label y la imagen del indice inicial", () => {
|
|
29
|
+
const detalle = fixtureDetalle()
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<Lightbox imagenes={detalle.producto.imagenes} ctx={mockCtx()} indiceInicial={1} onCerrar={() => {}} />,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(html).toContain('role="dialog"')
|
|
35
|
+
expect(html).toContain('aria-modal="true"')
|
|
36
|
+
expect(html).toContain('aria-label="Imagen ampliada"')
|
|
37
|
+
expect(html).toContain("data-lightbox")
|
|
38
|
+
// El índice inicial (1) determina la imagen mostrada.
|
|
39
|
+
expect(html).toContain(`src="${detalle.producto.imagenes[1].url}"`)
|
|
40
|
+
expect(html).toContain(`alt="${detalle.producto.imagenes[1].alt}"`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("expone el boton de cierre (aria-label Cerrar) para cerrar con click", () => {
|
|
44
|
+
const detalle = fixtureDetalle()
|
|
45
|
+
const html = renderToStaticMarkup(
|
|
46
|
+
<Lightbox imagenes={detalle.producto.imagenes} ctx={mockCtx()} indiceInicial={0} onCerrar={() => {}} />,
|
|
47
|
+
)
|
|
48
|
+
expect(html).toContain('aria-label="Cerrar"')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("con varias imagenes muestra navegacion ‹ › y contador N / total", () => {
|
|
52
|
+
const detalle = fixtureDetalle()
|
|
53
|
+
const total = detalle.producto.imagenes.length
|
|
54
|
+
const html = renderToStaticMarkup(
|
|
55
|
+
<Lightbox imagenes={detalle.producto.imagenes} ctx={mockCtx()} indiceInicial={0} onCerrar={() => {}} />,
|
|
56
|
+
)
|
|
57
|
+
expect(html).toContain('aria-label="Imagen anterior"')
|
|
58
|
+
expect(html).toContain('aria-label="Imagen siguiente"')
|
|
59
|
+
expect(html).toContain("data-lightbox-contador")
|
|
60
|
+
expect(html).toContain(`1 / ${total}`)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("con una sola imagen NO muestra navegacion ni contador", () => {
|
|
64
|
+
const detalle = fixtureDetalle()
|
|
65
|
+
const html = renderToStaticMarkup(
|
|
66
|
+
<Lightbox imagenes={[detalle.producto.imagenes[0]]} ctx={mockCtx()} indiceInicial={0} onCerrar={() => {}} />,
|
|
67
|
+
)
|
|
68
|
+
expect(html).not.toContain('aria-label="Imagen anterior"')
|
|
69
|
+
expect(html).not.toContain('aria-label="Imagen siguiente"')
|
|
70
|
+
expect(html).not.toContain("data-lightbox-contador")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("indiceInicial fuera de rango degrada a null (no revienta)", () => {
|
|
74
|
+
const detalle = fixtureDetalle()
|
|
75
|
+
const html = renderToStaticMarkup(
|
|
76
|
+
<Lightbox imagenes={detalle.producto.imagenes} ctx={mockCtx()} indiceInicial={99} onCerrar={() => {}} />,
|
|
77
|
+
)
|
|
78
|
+
expect(html).toBe("")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("respeta prefers-reduced-motion (motion-reduce:transition-none)", () => {
|
|
82
|
+
const detalle = fixtureDetalle()
|
|
83
|
+
const html = renderToStaticMarkup(
|
|
84
|
+
<Lightbox imagenes={detalle.producto.imagenes} ctx={mockCtx()} indiceInicial={0} onCerrar={() => {}} />,
|
|
85
|
+
)
|
|
86
|
+
expect(html).toContain("motion-reduce:transition-none")
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("guard de estandar (cero <img>/<a> directos)", () => {
|
|
91
|
+
it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
|
|
92
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
93
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
|
+
import type { RenderContext } from "../context"
|
|
5
|
+
import type { ImagenGaleria } from "./gallery"
|
|
6
|
+
|
|
7
|
+
/** Índice de la imagen siguiente (con wrap-around). Puro para poder testearlo sin DOM. */
|
|
8
|
+
export function siguienteIndice(actual: number, total: number): number {
|
|
9
|
+
return total > 0 ? (actual + 1) % total : 0
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Índice de la imagen anterior (con wrap-around). Puro para poder testearlo sin DOM. */
|
|
13
|
+
export function anteriorIndice(actual: number, total: number): number {
|
|
14
|
+
return total > 0 ? (actual - 1 + total) % total : 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lightbox / zoom a pantalla completa de la galería PDP. Aislado en su propio
|
|
19
|
+
* módulo `"use client"` (lección "use client": los hooks van SOLO en archivos
|
|
20
|
+
* client dedicados, nunca en módulos que exporten tipos/constantes consumidos
|
|
21
|
+
* por el servidor). `gallery.tsx` lo importa y lo monta bajo demanda.
|
|
22
|
+
*
|
|
23
|
+
* - Overlay `fixed inset-0` con scrim oscuro semitransparente.
|
|
24
|
+
* - Navegación ‹ › entre imágenes (wrap-around) + teclas ← →.
|
|
25
|
+
* - Cierra con Esc o click en el scrim.
|
|
26
|
+
* - Foco atrapado dentro del diálogo (Tab/Shift+Tab ciclan entre los botones);
|
|
27
|
+
* el foco entra al botón "Cerrar" al abrir.
|
|
28
|
+
* - Respeta `prefers-reduced-motion` (sin transición cuando el usuario la reduce).
|
|
29
|
+
*/
|
|
30
|
+
export function Lightbox({
|
|
31
|
+
imagenes,
|
|
32
|
+
ctx,
|
|
33
|
+
indiceInicial,
|
|
34
|
+
onCerrar,
|
|
35
|
+
}: {
|
|
36
|
+
imagenes: ImagenGaleria[]
|
|
37
|
+
ctx: RenderContext
|
|
38
|
+
indiceInicial: number
|
|
39
|
+
onCerrar: () => void
|
|
40
|
+
}) {
|
|
41
|
+
const { Imagen } = ctx.componentes
|
|
42
|
+
const total = imagenes.length
|
|
43
|
+
const [activo, setActivo] = useState(indiceInicial)
|
|
44
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
const cerrarRef = useRef<HTMLButtonElement>(null)
|
|
46
|
+
|
|
47
|
+
const irAnterior = useCallback(() => setActivo((a) => anteriorIndice(a, total)), [total])
|
|
48
|
+
const irSiguiente = useCallback(() => setActivo((a) => siguienteIndice(a, total)), [total])
|
|
49
|
+
|
|
50
|
+
// Foco inicial en "Cerrar" al montar.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
cerrarRef.current?.focus()
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// Teclado: Esc cierra, ← → navegan, Tab queda atrapado dentro del diálogo.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
function onKey(e: KeyboardEvent) {
|
|
58
|
+
if (e.key === "Escape") {
|
|
59
|
+
onCerrar()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (e.key === "ArrowLeft") {
|
|
63
|
+
irAnterior()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (e.key === "ArrowRight") {
|
|
67
|
+
irSiguiente()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (e.key === "Tab") {
|
|
71
|
+
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>("button")
|
|
72
|
+
if (!focusables || focusables.length === 0) return
|
|
73
|
+
const primero = focusables[0]
|
|
74
|
+
const ultimo = focusables[focusables.length - 1]
|
|
75
|
+
const activoEl = document.activeElement
|
|
76
|
+
if (e.shiftKey && activoEl === primero) {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
ultimo.focus()
|
|
79
|
+
} else if (!e.shiftKey && activoEl === ultimo) {
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
primero.focus()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
document.addEventListener("keydown", onKey)
|
|
86
|
+
return () => document.removeEventListener("keydown", onKey)
|
|
87
|
+
}, [onCerrar, irAnterior, irSiguiente])
|
|
88
|
+
|
|
89
|
+
const actual = imagenes[activo]
|
|
90
|
+
if (!actual) return null
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
ref={dialogRef}
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-label="Imagen ampliada"
|
|
98
|
+
data-lightbox
|
|
99
|
+
className="fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10"
|
|
100
|
+
style={{ background: "var(--cms-scrim, rgba(0,0,0,.86))" }}
|
|
101
|
+
onClick={onCerrar}
|
|
102
|
+
>
|
|
103
|
+
<button
|
|
104
|
+
ref={cerrarRef}
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={onCerrar}
|
|
107
|
+
aria-label="Cerrar"
|
|
108
|
+
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full text-[18px] leading-none"
|
|
109
|
+
style={{
|
|
110
|
+
background: "var(--cms-superficie, #fff)",
|
|
111
|
+
color: "var(--cms-texto, #111)",
|
|
112
|
+
border: "var(--cms-grosor-borde, 1px) solid var(--cms-borde, #e5e5e5)",
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
×
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{total > 1 ? (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={(e) => {
|
|
122
|
+
e.stopPropagation()
|
|
123
|
+
irAnterior()
|
|
124
|
+
}}
|
|
125
|
+
aria-label="Imagen anterior"
|
|
126
|
+
className="absolute left-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full text-[20px] leading-none"
|
|
127
|
+
style={{
|
|
128
|
+
background: "var(--cms-superficie, #fff)",
|
|
129
|
+
color: "var(--cms-texto, #111)",
|
|
130
|
+
border: "var(--cms-grosor-borde, 1px) solid var(--cms-borde, #e5e5e5)",
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
‹
|
|
134
|
+
</button>
|
|
135
|
+
) : null}
|
|
136
|
+
|
|
137
|
+
<div
|
|
138
|
+
className="relative flex max-h-full max-w-full items-center justify-center overflow-hidden"
|
|
139
|
+
style={{ borderRadius: "var(--cms-radio, 0)" }}
|
|
140
|
+
onClick={(e) => e.stopPropagation()}
|
|
141
|
+
>
|
|
142
|
+
<Imagen
|
|
143
|
+
src={actual.url}
|
|
144
|
+
alt={actual.alt}
|
|
145
|
+
className="max-h-[86vh] max-w-[90vw] object-contain transition-opacity duration-200 motion-reduce:transition-none"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{total > 1 ? (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={(e) => {
|
|
153
|
+
e.stopPropagation()
|
|
154
|
+
irSiguiente()
|
|
155
|
+
}}
|
|
156
|
+
aria-label="Imagen siguiente"
|
|
157
|
+
className="absolute right-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full text-[20px] leading-none"
|
|
158
|
+
style={{
|
|
159
|
+
background: "var(--cms-superficie, #fff)",
|
|
160
|
+
color: "var(--cms-texto, #111)",
|
|
161
|
+
border: "var(--cms-grosor-borde, 1px) solid var(--cms-borde, #e5e5e5)",
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
›
|
|
165
|
+
</button>
|
|
166
|
+
) : null}
|
|
167
|
+
|
|
168
|
+
{total > 1 ? (
|
|
169
|
+
<span
|
|
170
|
+
data-lightbox-contador
|
|
171
|
+
className="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-[11px] font-medium"
|
|
172
|
+
style={{
|
|
173
|
+
background: "var(--cms-superficie, #fff)",
|
|
174
|
+
color: "var(--cms-texto, #111)",
|
|
175
|
+
border: "var(--cms-grosor-borde, 1px) solid var(--cms-borde, #e5e5e5)",
|
|
176
|
+
fontFamily: "var(--cms-fuente-mono, monospace)",
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{activo + 1} / {total}
|
|
180
|
+
</span>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|