@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,161 @@
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 {
6
+ MegaMenu,
7
+ megaMenuSchema,
8
+ megaMenuDefaults,
9
+ type MegaMenuData,
10
+ type MegaMenuVariante,
11
+ } from "./mega-menu"
12
+ import { mockCtx } from "../test/mock-ctx"
13
+
14
+ const columnas = [
15
+ {
16
+ titulo: "Superiores",
17
+ enlaces: [
18
+ { texto: "Camisetas", href: "/ropa/camisetas" },
19
+ { texto: "Hoodies y buzos", href: "/ropa/hoodies" },
20
+ ],
21
+ },
22
+ {
23
+ titulo: "Inferiores",
24
+ enlaces: [{ texto: "Jeans", href: "/ropa/jeans" }],
25
+ },
26
+ {
27
+ titulo: "Colecciones",
28
+ enlaces: [{ texto: "Nuevos drops", href: "/colecciones/nuevos-drops" }],
29
+ },
30
+ ]
31
+
32
+ const destacado = {
33
+ imagenUrl: "https://ejemplo.com/drop-07.jpg",
34
+ titulo: "Drop 07 — En la calle",
35
+ href: "/colecciones/drop-07",
36
+ }
37
+ const destacado2 = {
38
+ imagenUrl: "https://ejemplo.com/drop-08.jpg",
39
+ titulo: "Drop 08 — Neón",
40
+ href: "/colecciones/drop-08",
41
+ }
42
+
43
+ const verTodo = { texto: "Ver todo", href: "/novedades" }
44
+
45
+ const base = (overrides?: Partial<MegaMenuData>): MegaMenuData => ({
46
+ variante: "columnas",
47
+ columnas,
48
+ destacados: [],
49
+ ...overrides,
50
+ })
51
+
52
+ const render = (megaMenu: MegaMenuData) =>
53
+ renderToStaticMarkup(<MegaMenu megaMenu={megaMenu} ctx={mockCtx()} />)
54
+
55
+ const VARIANTES: MegaMenuVariante[] = ["columnas", "columnas-imagen", "grid-imagenes", "simple"]
56
+
57
+ describe("MegaMenu", () => {
58
+ it("los defaults pasan el schema", () => {
59
+ expect(megaMenuSchema.safeParse(megaMenuDefaults).success).toBe(true)
60
+ })
61
+
62
+ it("el schema declara las 4 variantes como segmented", () => {
63
+ const variante = megaMenuSchema.shape.variante
64
+ expect(variante.description).toBe("segmented")
65
+ expect(Object.keys(variante.def.entries)).toEqual(VARIANTES)
66
+ })
67
+
68
+ for (const variante of VARIANTES) {
69
+ describe(`variante ${variante}`, () => {
70
+ it("renderiza sin crash con columnas + destacados + verTodo", () => {
71
+ const html = render(base({ variante, destacados: [destacado, destacado2], verTodo }))
72
+ expect(html).toContain("<div")
73
+ })
74
+
75
+ it("está oculto en mobile (hidden md:)", () => {
76
+ const html = render(base({ variante }))
77
+ expect(html).toContain("hidden")
78
+ expect(html).toMatch(/md:(grid|block)/)
79
+ })
80
+
81
+ it("muestra verTodo cuando está presente", () => {
82
+ const html = render(base({ variante, verTodo }))
83
+ expect(html).toContain(verTodo.texto)
84
+ expect(html).toContain(`href="${verTodo.href}"`)
85
+ })
86
+
87
+ it("sin verTodo no lo muestra", () => {
88
+ const html = render(base({ variante }))
89
+ expect(html).not.toContain(`href="${verTodo.href}"`)
90
+ })
91
+ })
92
+ }
93
+
94
+ it("columnas: pinta títulos y enlaces via ctx.componentes.Enlace", () => {
95
+ const html = render(base({ variante: "columnas" }))
96
+ for (const columna of columnas) {
97
+ expect(html).toContain(columna.titulo)
98
+ for (const enlace of columna.enlaces) {
99
+ expect(html).toContain(enlace.texto)
100
+ expect(html).toContain(`href="${enlace.href}"`)
101
+ }
102
+ }
103
+ })
104
+
105
+ it("columnas: NO muestra los destacados aunque se pasen", () => {
106
+ const html = render(base({ variante: "columnas", destacados: [destacado] }))
107
+ expect(html).not.toContain(`href="${destacado.href}"`)
108
+ expect(html).not.toContain(`src="${destacado.imagenUrl}"`)
109
+ })
110
+
111
+ it("columnas-imagen: muestra la imagen destacada y su enlace", () => {
112
+ const html = render(base({ variante: "columnas-imagen", destacados: [destacado] }))
113
+ expect(html).toContain(`href="${destacado.href}"`)
114
+ expect(html).toContain(`src="${destacado.imagenUrl}"`)
115
+ expect(html).toContain(destacado.titulo)
116
+ })
117
+
118
+ it("columnas-imagen: admite MÁS DE UN destacado", () => {
119
+ const html = render(base({ variante: "columnas-imagen", destacados: [destacado, destacado2] }))
120
+ expect(html).toContain(`href="${destacado.href}"`)
121
+ expect(html).toContain(`href="${destacado2.href}"`)
122
+ expect(html).toContain(`src="${destacado.imagenUrl}"`)
123
+ expect(html).toContain(`src="${destacado2.imagenUrl}"`)
124
+ })
125
+
126
+ it("grid-imagenes: renderiza todos los destacados como grid de imágenes", () => {
127
+ const html = render(base({ variante: "grid-imagenes", destacados: [destacado, destacado2] }))
128
+ expect(html).toContain(`src="${destacado.imagenUrl}"`)
129
+ expect(html).toContain(`src="${destacado2.imagenUrl}"`)
130
+ expect(html).toContain(destacado.titulo)
131
+ expect(html).toContain(destacado2.titulo)
132
+ })
133
+
134
+ it("simple: pinta las columnas pero NO los destacados", () => {
135
+ const html = render(base({ variante: "simple", destacados: [destacado] }))
136
+ expect(html).toContain(columnas[0].titulo)
137
+ expect(html).not.toContain(`src="${destacado.imagenUrl}"`)
138
+ })
139
+
140
+ it("destacado con imagenUrl vacío usa el placeholder (sin <img src=\"\">)", () => {
141
+ const html = render(
142
+ base({ variante: "columnas-imagen", destacados: [{ ...destacado, imagenUrl: "" }] }),
143
+ )
144
+ expect(html).not.toContain('<img src=""')
145
+ expect(html).toContain('role="img"')
146
+ })
147
+
148
+ it("aplica la clase de animación de apertura (cms-mega-panel)", () => {
149
+ const html = render(base({ variante: "columnas" }))
150
+ expect(html).toContain("cms-mega-panel")
151
+ expect(html).toContain("prefers-reduced-motion")
152
+ })
153
+
154
+ it("no emite <a> ni <img> directos: todo pasa por ctx.componentes", () => {
155
+ const rutaFuente = fileURLToPath(new URL("./mega-menu.tsx", import.meta.url))
156
+ const fuente = readFileSync(rutaFuente, "utf-8")
157
+
158
+ expect(fuente).not.toMatch(/<a[\s>]/)
159
+ expect(fuente).not.toMatch(/<img[\s>]/)
160
+ })
161
+ })
@@ -0,0 +1,317 @@
1
+ import { z } from "zod"
2
+ import type { RenderContext } from "../context"
3
+
4
+ /**
5
+ * MegaMenu de escritorio — estética "Kinetic Street" (superficie con radio y
6
+ * sombra suaves, barra de degradado, tokens `--cms-*` CON fallback). Es
7
+ * PRESENTACIONAL (sin estado): la apertura la controla el header vía
8
+ * `group-hover`/`group-focus-within` en el `<nav>` que lo envuelve. Oculto en
9
+ * mobile (`hidden md:block`) — en mobile el mismo contenido va al DrawerMovil.
10
+ *
11
+ * 100% editable/combinable desde el CMS: su schema Zod vive acá (co-locado) y
12
+ * el header lo re-usa anidado en `HeaderProps.megaMenu`, así el ChromeEditor
13
+ * autogenera los controles sin acoplar el header a la forma del mega-menú.
14
+ *
15
+ * Variantes (`variante`, `.describe("segmented")`):
16
+ * - `columnas` → grid de columnas de enlaces.
17
+ * - `columnas-imagen` → columnas + celda(s) destacada(s) con imagen al lado.
18
+ * - `grid-imagenes` → grid de tarjetas de imagen (destacados) + enlaces.
19
+ * - `simple` → lista compacta de una sola columna (dropdown liviano).
20
+ *
21
+ * Campos editables: `columnas` (lista de `{ titulo, enlaces[] }`), `destacados`
22
+ * (lista de `{ imagenUrl, titulo, href }`, se permite MÁS DE UNO) y `verTodo`
23
+ * opcional (`{ texto, href }`). Las imágenes pasan por `ctx.componentes.Imagen`.
24
+ */
25
+
26
+ // ── Schema (co-locado; el header lo re-usa) ──────────────────────────────────
27
+
28
+ export const megaMenuEnlaceSchema = z.object({
29
+ texto: z.string().min(1),
30
+ href: z.string().min(1),
31
+ })
32
+ export const megaMenuColumnaSchema = z.object({
33
+ titulo: z.string().min(1),
34
+ enlaces: z.array(megaMenuEnlaceSchema),
35
+ })
36
+ export const megaMenuDestacadoSchema = z.object({
37
+ imagenUrl: z.string(),
38
+ titulo: z.string().min(1),
39
+ href: z.string().min(1),
40
+ })
41
+ export const megaMenuVerTodoSchema = z.object({
42
+ texto: z.string().min(1),
43
+ href: z.string().min(1),
44
+ })
45
+ export const megaMenuSchema = z.object({
46
+ variante: z
47
+ .enum(["columnas", "columnas-imagen", "grid-imagenes", "simple"])
48
+ .describe("segmented"),
49
+ columnas: z.array(megaMenuColumnaSchema),
50
+ destacados: z.array(megaMenuDestacadoSchema),
51
+ verTodo: megaMenuVerTodoSchema.optional(),
52
+ })
53
+
54
+ export type MegaMenuEnlace = z.infer<typeof megaMenuEnlaceSchema>
55
+ export type MegaMenuColumna = z.infer<typeof megaMenuColumnaSchema>
56
+ export type MegaMenuDestacado = z.infer<typeof megaMenuDestacadoSchema>
57
+ export type MegaMenuVerTodo = z.infer<typeof megaMenuVerTodoSchema>
58
+ export type MegaMenuData = z.infer<typeof megaMenuSchema>
59
+ export type MegaMenuVariante = MegaMenuData["variante"]
60
+
61
+ export const megaMenuDefaults: MegaMenuData = {
62
+ variante: "columnas-imagen",
63
+ columnas: [
64
+ {
65
+ titulo: "Superiores",
66
+ enlaces: [
67
+ { texto: "Camisetas", href: "/ropa/camisetas" },
68
+ { texto: "Hoodies y buzos", href: "/ropa/hoodies" },
69
+ ],
70
+ },
71
+ {
72
+ titulo: "Inferiores",
73
+ enlaces: [{ texto: "Jeans", href: "/ropa/jeans" }],
74
+ },
75
+ {
76
+ titulo: "Colecciones",
77
+ enlaces: [{ texto: "Nuevos drops", href: "/colecciones/nuevos-drops" }],
78
+ },
79
+ ],
80
+ destacados: [
81
+ { imagenUrl: "", titulo: "Drop 07 — En la calle", href: "/colecciones/drop-07" },
82
+ ],
83
+ verTodo: { texto: "Ver todo", href: "/novedades" },
84
+ }
85
+
86
+ // ── Transición de apertura (respeta prefers-reduced-motion) ──────────────────
87
+
88
+ /**
89
+ * El panel pasa de `display:none` a `display:block` (group-hover en el header):
90
+ * ese cambio RE-DISPARA la animación CSS, así que basta un keyframe en el panel.
91
+ */
92
+ function EstiloMega() {
93
+ return (
94
+ <style
95
+ dangerouslySetInnerHTML={{
96
+ __html:
97
+ "@keyframes cms-mega-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}" +
98
+ ".cms-mega-panel{animation:cms-mega-in .18s ease-out}" +
99
+ "@media (prefers-reduced-motion: reduce){.cms-mega-panel{animation:none}}",
100
+ }}
101
+ />
102
+ )
103
+ }
104
+
105
+ // ── Piezas de render ─────────────────────────────────────────────────────────
106
+
107
+ const claseTituloColumna = "text-[11px] font-semibold uppercase tracking-[0.1em]"
108
+
109
+ function Columna({ columna, ctx }: { columna: MegaMenuColumna; ctx: RenderContext }) {
110
+ const { Enlace } = ctx.componentes
111
+ return (
112
+ <div className="flex flex-col gap-2.5 text-[12.5px]">
113
+ <p className={`m-0 ${claseTituloColumna}`} style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
114
+ {columna.titulo}
115
+ </p>
116
+ {columna.enlaces.map((enlace) => (
117
+ <Enlace key={enlace.href} href={enlace.href} className="block transition-opacity hover:opacity-70">
118
+ {enlace.texto}
119
+ </Enlace>
120
+ ))}
121
+ </div>
122
+ )
123
+ }
124
+
125
+ function Destacado({
126
+ destacado,
127
+ ctx,
128
+ alto = "h-[180px]",
129
+ }: {
130
+ destacado: MegaMenuDestacado
131
+ ctx: RenderContext
132
+ alto?: string
133
+ }) {
134
+ const { Imagen, Enlace } = ctx.componentes
135
+ return (
136
+ <Enlace
137
+ href={destacado.href}
138
+ className={`relative block overflow-hidden ${alto}`}
139
+ >
140
+ <span
141
+ className="absolute inset-0 block"
142
+ style={{ borderRadius: "var(--cms-radio-md,14px)", overflow: "hidden" }}
143
+ >
144
+ <Imagen
145
+ src={destacado.imagenUrl}
146
+ alt={destacado.titulo}
147
+ className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 hover:scale-105"
148
+ />
149
+ <span
150
+ className="absolute inset-x-0 bottom-0 block px-3.5 pb-3 pt-9 text-[13px] font-semibold text-white"
151
+ style={{ background: "linear-gradient(transparent, rgba(0,0,0,.65))" }}
152
+ >
153
+ {destacado.titulo}
154
+ </span>
155
+ </span>
156
+ </Enlace>
157
+ )
158
+ }
159
+
160
+ function VerTodo({ verTodo, ctx }: { verTodo: MegaMenuVerTodo; ctx: RenderContext }) {
161
+ const { Enlace } = ctx.componentes
162
+ return (
163
+ <div className="mt-6 pt-4" style={{ borderTop: "var(--cms-grosor-borde,1px) solid var(--cms-borde,#dbd6c9)" }}>
164
+ <Enlace
165
+ href={verTodo.href}
166
+ className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.1em] transition-opacity hover:opacity-70"
167
+ >
168
+ {verTodo.texto}
169
+ <span aria-hidden>→</span>
170
+ </Enlace>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ /** Barra fina de degradado kinético en el borde superior del panel. */
176
+ function BarraKinetic() {
177
+ return (
178
+ <div
179
+ aria-hidden
180
+ style={{
181
+ height: "4px",
182
+ background: "var(--cms-degradado,linear-gradient(120deg,#3b2bff,#ff3b2e))",
183
+ borderTopLeftRadius: "var(--cms-radio-lg,22px)",
184
+ borderTopRightRadius: "var(--cms-radio-lg,22px)",
185
+ }}
186
+ />
187
+ )
188
+ }
189
+
190
+ // ── Cuerpos por variante ─────────────────────────────────────────────────────
191
+
192
+ function CuerpoColumnas({
193
+ columnas,
194
+ destacados,
195
+ ctx,
196
+ conImagen,
197
+ }: {
198
+ columnas: MegaMenuColumna[]
199
+ destacados: MegaMenuDestacado[]
200
+ ctx: RenderContext
201
+ conImagen: boolean
202
+ }) {
203
+ const n = columnas.length || 1
204
+ const mostrarDestacados = conImagen && destacados.length > 0
205
+ return (
206
+ <div
207
+ className="grid gap-8"
208
+ style={{
209
+ gridTemplateColumns: mostrarDestacados
210
+ ? `repeat(${n}, minmax(0,1fr)) 1.3fr`
211
+ : `repeat(${n}, minmax(0,1fr))`,
212
+ }}
213
+ >
214
+ {columnas.map((columna) => (
215
+ <Columna key={columna.titulo} columna={columna} ctx={ctx} />
216
+ ))}
217
+ {mostrarDestacados ? (
218
+ <div
219
+ className="grid gap-3"
220
+ style={{ gridTemplateColumns: destacados.length > 1 ? "1fr 1fr" : "1fr" }}
221
+ >
222
+ {destacados.map((d) => (
223
+ <Destacado key={d.href} destacado={d} ctx={ctx} />
224
+ ))}
225
+ </div>
226
+ ) : null}
227
+ </div>
228
+ )
229
+ }
230
+
231
+ function CuerpoGridImagenes({
232
+ columnas,
233
+ destacados,
234
+ ctx,
235
+ }: {
236
+ columnas: MegaMenuColumna[]
237
+ destacados: MegaMenuDestacado[]
238
+ ctx: RenderContext
239
+ }) {
240
+ const cols = Math.min(Math.max(destacados.length, 1), 4)
241
+ return (
242
+ <div className="flex flex-col gap-7">
243
+ {destacados.length > 0 ? (
244
+ <div
245
+ className="grid gap-3"
246
+ style={{ gridTemplateColumns: `repeat(${cols}, minmax(0,1fr))` }}
247
+ >
248
+ {destacados.map((d) => (
249
+ <Destacado key={d.href} destacado={d} ctx={ctx} alto="h-[220px]" />
250
+ ))}
251
+ </div>
252
+ ) : null}
253
+ {columnas.length > 0 ? (
254
+ <div
255
+ className="grid gap-8"
256
+ style={{ gridTemplateColumns: `repeat(${columnas.length}, minmax(0,1fr))` }}
257
+ >
258
+ {columnas.map((columna) => (
259
+ <Columna key={columna.titulo} columna={columna} ctx={ctx} />
260
+ ))}
261
+ </div>
262
+ ) : null}
263
+ </div>
264
+ )
265
+ }
266
+
267
+ function CuerpoSimple({ columnas, ctx }: { columnas: MegaMenuColumna[]; ctx: RenderContext }) {
268
+ return (
269
+ <div className="flex min-w-[180px] flex-col gap-5">
270
+ {columnas.map((columna) => (
271
+ <Columna key={columna.titulo} columna={columna} ctx={ctx} />
272
+ ))}
273
+ </div>
274
+ )
275
+ }
276
+
277
+ // ── Panel ─────────────────────────────────────────────────────────────────────
278
+
279
+ export function MegaMenu({ megaMenu, ctx }: { megaMenu: MegaMenuData; ctx: RenderContext }) {
280
+ const { variante, columnas, destacados, verTodo } = megaMenu
281
+
282
+ let cuerpo: React.ReactNode
283
+ if (variante === "grid-imagenes") {
284
+ cuerpo = <CuerpoGridImagenes columnas={columnas} destacados={destacados} ctx={ctx} />
285
+ } else if (variante === "simple") {
286
+ cuerpo = <CuerpoSimple columnas={columnas} ctx={ctx} />
287
+ } else {
288
+ cuerpo = (
289
+ <CuerpoColumnas
290
+ columnas={columnas}
291
+ destacados={destacados}
292
+ ctx={ctx}
293
+ conImagen={variante === "columnas-imagen"}
294
+ />
295
+ )
296
+ }
297
+
298
+ return (
299
+ <div
300
+ className="cms-mega-panel hidden overflow-hidden md:block"
301
+ style={{
302
+ background: "var(--cms-superficie,#fff)",
303
+ color: "var(--cms-texto,#0c0c10)",
304
+ border: "var(--cms-grosor-borde,1px) solid var(--cms-borde,#dbd6c9)",
305
+ borderRadius: "var(--cms-radio-lg,22px)",
306
+ boxShadow: "var(--cms-sombra-lg,0 30px 70px -28px rgba(12,12,16,.4))",
307
+ }}
308
+ >
309
+ <EstiloMega />
310
+ <BarraKinetic />
311
+ <div className="p-7">
312
+ {cuerpo}
313
+ {verTodo ? <VerTodo verTodo={verTodo} ctx={ctx} /> : null}
314
+ </div>
315
+ </div>
316
+ )
317
+ }
@@ -0,0 +1,111 @@
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 { MiniCart } from "./mini-cart"
6
+ import { mockCtx, fixtureCarrito } from "../test/mock-ctx"
7
+
8
+ const render = (props: Partial<Parameters<typeof MiniCart>[0]> = {}) =>
9
+ renderToStaticMarkup(
10
+ <MiniCart
11
+ abierto={true}
12
+ onCerrar={() => {}}
13
+ carrito={fixtureCarrito()}
14
+ ctx={mockCtx()}
15
+ {...props}
16
+ />,
17
+ )
18
+
19
+ describe("MiniCart", () => {
20
+ it("abierto:false no renderiza nada", () => {
21
+ expect(render({ abierto: false })).toBe("")
22
+ })
23
+
24
+ it("es un diálogo modal con scrim y botón de cerrar accesible", () => {
25
+ const html = render()
26
+ expect(html).toContain('role="dialog"')
27
+ expect(html).toContain('aria-modal="true"')
28
+ expect(html).toContain('aria-label="Tu bolsa"')
29
+ expect(html).toContain("bg-black/50")
30
+ expect(html).toContain('aria-label="Cerrar bolsa"')
31
+ })
32
+
33
+ it("aplica la animación de entrada (cms-minicart-panel) y respeta reduced-motion", () => {
34
+ const html = render()
35
+ expect(html).toContain("cms-minicart-panel")
36
+ expect(html).toContain("prefers-reduced-motion")
37
+ })
38
+
39
+ it("cabecera muestra el conteo total: 'Tu bolsa (N)'", () => {
40
+ const html = render({ carrito: fixtureCarrito({ count: 3 }) })
41
+ expect(html).toContain("Tu bolsa (3)")
42
+ })
43
+
44
+ it("pinta cada línea: título, variante, cantidad y thumbnail via ctx.componentes.Imagen", () => {
45
+ const html = render()
46
+ expect(html).toContain("Sneaker Runner Blanco")
47
+ expect(html).toContain("Blanco / 40")
48
+ expect(html).toContain("Hoodie Heavy Negro")
49
+ expect(html).toContain("Negro / M")
50
+ // href del producto
51
+ expect(html).toContain('href="/products/sneaker-runner-blanco"')
52
+ // thumbnail real (Imagen) y placeholder para la línea sin thumbnail
53
+ expect(html).toContain('src="https://picsum.photos/seed/cart-1/80/100"')
54
+ // cantidad "N ×"
55
+ expect(html).toContain("2 ×")
56
+ })
57
+
58
+ it("formatea los montos de línea con Intl.NumberFormat (es-CO / COP, sin decimales)", () => {
59
+ const html = render()
60
+ const esperado = new Intl.NumberFormat("es-CO", {
61
+ style: "currency",
62
+ currency: "COP",
63
+ maximumFractionDigits: 0,
64
+ }).format(289900)
65
+ expect(html).toContain(esperado)
66
+ })
67
+
68
+ it("muestra el subtotal formateado y las 2 CTAs (Ver bolsa / Ir a pagar)", () => {
69
+ const html = render()
70
+ const subtotal = new Intl.NumberFormat("es-CO", {
71
+ style: "currency",
72
+ currency: "COP",
73
+ maximumFractionDigits: 0,
74
+ }).format(649700)
75
+ expect(html).toContain("Subtotal")
76
+ expect(html).toContain(subtotal)
77
+ expect(html).toContain("Ver bolsa")
78
+ expect(html).toContain('href="/cart"')
79
+ expect(html).toContain("Ir a pagar")
80
+ expect(html).toContain('href="/checkout"')
81
+ })
82
+
83
+ it("carrito null → estado vacío con enlace al catálogo", () => {
84
+ const html = render({ carrito: null })
85
+ expect(html).toContain("Tu bolsa está vacía")
86
+ expect(html).toContain("Seguir comprando")
87
+ expect(html).toContain('href="/catalogo"')
88
+ expect(html).toContain("Tu bolsa (0)")
89
+ // sin CTAs de checkout en estado vacío
90
+ expect(html).not.toContain("Ir a pagar")
91
+ })
92
+
93
+ it("carrito con items:[] → estado vacío", () => {
94
+ const html = render({ carrito: fixtureCarrito({ items: [], count: 0 }) })
95
+ expect(html).toContain("Tu bolsa está vacía")
96
+ expect(html).not.toContain("Ir a pagar")
97
+ })
98
+
99
+ it("no emite <a> ni <img> directos: todo pasa por ctx.componentes", () => {
100
+ const rutaFuente = fileURLToPath(new URL("./mini-cart.tsx", import.meta.url))
101
+ const fuente = readFileSync(rutaFuente, "utf-8")
102
+ expect(fuente).not.toMatch(/<a[\s>]/)
103
+ expect(fuente).not.toMatch(/<img[\s>]/)
104
+ })
105
+
106
+ it("no declara 'use client' en context.ts (tipos server-safe)", () => {
107
+ const rutaCtx = fileURLToPath(new URL("../context.ts", import.meta.url))
108
+ const fuente = readFileSync(rutaCtx, "utf-8")
109
+ expect(fuente).not.toContain("use client")
110
+ })
111
+ })