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