@arroyavecommerce/theme-kinetic 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/docs/design/README.md +22 -0
  2. package/docs/design/kinetic-street-concept.html +558 -0
  3. package/package.json +45 -0
  4. package/src/blocks/_shared.tsx +174 -0
  5. package/src/blocks/carrusel.tsx +109 -0
  6. package/src/blocks/category-tiles.test.tsx +59 -0
  7. package/src/blocks/category-tiles.tsx +70 -0
  8. package/src/blocks/editorial-banner.test.tsx +96 -0
  9. package/src/blocks/editorial-banner.tsx +205 -0
  10. package/src/blocks/faq.test.tsx +68 -0
  11. package/src/blocks/faq.tsx +122 -0
  12. package/src/blocks/featured-collection.test.tsx +80 -0
  13. package/src/blocks/featured-collection.tsx +97 -0
  14. package/src/blocks/features.test.tsx +71 -0
  15. package/src/blocks/features.tsx +159 -0
  16. package/src/blocks/hero.test.tsx +75 -0
  17. package/src/blocks/hero.tsx +290 -0
  18. package/src/blocks/index.tsx +35 -0
  19. package/src/blocks/lookbook.test.tsx +80 -0
  20. package/src/blocks/lookbook.tsx +117 -0
  21. package/src/blocks/marquee.test.tsx +54 -0
  22. package/src/blocks/marquee.tsx +114 -0
  23. package/src/blocks/newsletter.test.tsx +60 -0
  24. package/src/blocks/newsletter.tsx +105 -0
  25. package/src/blocks/product-grid.test.tsx +160 -0
  26. package/src/blocks/product-grid.tsx +76 -0
  27. package/src/blocks/promo-countdown.tsx +75 -0
  28. package/src/blocks/promo.test.tsx +71 -0
  29. package/src/blocks/promo.tsx +120 -0
  30. package/src/blocks/rich-text.test.tsx +67 -0
  31. package/src/blocks/rich-text.tsx +75 -0
  32. package/src/blocks/spacer.test.tsx +54 -0
  33. package/src/blocks/spacer.tsx +56 -0
  34. package/src/blocks/testimonials.test.tsx +74 -0
  35. package/src/blocks/testimonials.tsx +104 -0
  36. package/src/blocks/video.test.tsx +159 -0
  37. package/src/blocks/video.tsx +226 -0
  38. package/src/catalog/buy-box.test.tsx +225 -0
  39. package/src/catalog/buy-box.tsx +353 -0
  40. package/src/catalog/config.test.ts +115 -0
  41. package/src/catalog/config.ts +167 -0
  42. package/src/catalog/filter-controls.test.tsx +149 -0
  43. package/src/catalog/filter-controls.tsx +187 -0
  44. package/src/catalog/gallery.test.tsx +201 -0
  45. package/src/catalog/gallery.tsx +212 -0
  46. package/src/catalog/lightbox.test.tsx +95 -0
  47. package/src/catalog/lightbox.tsx +184 -0
  48. package/src/catalog/pdp.test.tsx +309 -0
  49. package/src/catalog/pdp.tsx +354 -0
  50. package/src/catalog/plp-arreglos.test.tsx +235 -0
  51. package/src/catalog/plp-inline.tsx +54 -0
  52. package/src/catalog/plp-overlay.tsx +129 -0
  53. package/src/catalog/plp-parts.test.tsx +146 -0
  54. package/src/catalog/plp-parts.tsx +292 -0
  55. package/src/catalog/plp-pills.tsx +44 -0
  56. package/src/catalog/plp-sidebar.tsx +47 -0
  57. package/src/catalog/plp.test.tsx +319 -0
  58. package/src/catalog/plp.tsx +259 -0
  59. package/src/catalog/product-card.test.tsx +221 -0
  60. package/src/catalog/product-card.tsx +411 -0
  61. package/src/catalog/registry.tsx +16 -0
  62. package/src/catalog/related.test.tsx +52 -0
  63. package/src/catalog/related.tsx +40 -0
  64. package/src/catalog/reviews.test.tsx +56 -0
  65. package/src/catalog/reviews.tsx +116 -0
  66. package/src/catalog/skeletons.test.tsx +61 -0
  67. package/src/catalog/skeletons.tsx +103 -0
  68. package/src/chrome/announcement-bar.test.tsx +86 -0
  69. package/src/chrome/announcement-bar.tsx +173 -0
  70. package/src/chrome/drawer-movil.test.tsx +113 -0
  71. package/src/chrome/drawer-movil.tsx +342 -0
  72. package/src/chrome/footer.test.tsx +133 -0
  73. package/src/chrome/footer.tsx +603 -0
  74. package/src/chrome/header.test.tsx +165 -0
  75. package/src/chrome/header.tsx +592 -0
  76. package/src/chrome/index.ts +26 -0
  77. package/src/chrome/mega-menu.test.tsx +161 -0
  78. package/src/chrome/mega-menu.tsx +317 -0
  79. package/src/chrome/mini-cart.test.tsx +111 -0
  80. package/src/chrome/mini-cart.tsx +320 -0
  81. package/src/content/home.ts +19 -0
  82. package/src/content/seed.test.ts +33 -0
  83. package/src/content/seed.ts +28 -0
  84. package/src/context.ts +104 -0
  85. package/src/icons/icons.test.tsx +118 -0
  86. package/src/icons/index.ts +136 -0
  87. package/src/icons/payment-icons.tsx +241 -0
  88. package/src/icons/social-icons.tsx +108 -0
  89. package/src/icons/svg-base.tsx +68 -0
  90. package/src/index.ts +49 -0
  91. package/src/puck-config.tsx +404 -0
  92. package/src/smoke.test.ts +8 -0
  93. package/src/template.test.tsx +88 -0
  94. package/src/test/mock-ctx.test.tsx +77 -0
  95. package/src/test/mock-ctx.tsx +236 -0
  96. package/src/theme-defaults.test.ts +9 -0
  97. package/src/theme.test.ts +82 -0
  98. package/src/theme.ts +195 -0
  99. package/tsconfig.json +4 -0
@@ -0,0 +1,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
+ }