@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,174 @@
1
+ import type { CSSProperties, ReactNode } from "react"
2
+
3
+ /**
4
+ * Utilidades compartidas por los bloques de home de la Fase 2 (features,
5
+ * marquee, featuredCollection, testimonials).
6
+ *
7
+ * ── `fondo` por bloque ───────────────────────────────────────────────────────
8
+ * Hoy el color de la home es global. Para poder alternar secciones claras y
9
+ * oscuras en la MISMA página, cada bloque expone un enum `fondo`. Se resuelve
10
+ * SIEMPRE con tokens del tema (`--cms-*`) + fallback, así el contraste es
11
+ * correcto en cualquier preset:
12
+ * - `claro` → fondo del tema sobre texto del tema.
13
+ * - `superficie` → superficie del tema (paneles) sobre texto del tema.
14
+ * - `oscuro` → invierte: usa el color de TEXTO como fondo y el de FONDO
15
+ * como texto (contraste garantizado en todos los presets).
16
+ * - `acento` → acento del tema sobre "texto sobre primario".
17
+ *
18
+ * ── Motion sutil ─────────────────────────────────────────────────────────────
19
+ * Reveal-on-scroll 100% CSS (sin JS, SSR-safe): el contenido es visible por
20
+ * defecto y sólo se anima cuando el navegador soporta scroll-driven animations
21
+ * y el usuario NO pidió `prefers-reduced-motion: reduce`. Progresivo: si no hay
22
+ * soporte, el contenido simplemente aparece sin animación.
23
+ */
24
+
25
+ // ── Fondo ────────────────────────────────────────────────────────────────────
26
+
27
+ export const FONDOS = ["claro", "oscuro", "acento", "superficie"] as const
28
+ export type Fondo = (typeof FONDOS)[number]
29
+
30
+ /** Opciones para el campo `select` de Puck (reutilizable en puck-config). */
31
+ export const FONDO_OPCIONES = [
32
+ { label: "Claro", value: "claro" },
33
+ { label: "Oscuro", value: "oscuro" },
34
+ { label: "Acento", value: "acento" },
35
+ { label: "Superficie", value: "superficie" },
36
+ ] as const
37
+
38
+ export type EstiloFondo = {
39
+ background: string
40
+ color: string
41
+ /** Color de texto secundario/apagado, legible sobre `background`. */
42
+ colorSuave: string
43
+ /** Borde legible sobre `background`. */
44
+ borde: string
45
+ /** Color de acento/eyebrow legible sobre `background`. */
46
+ acento: string
47
+ /** `true` cuando el fondo es oscuro y el texto va claro. */
48
+ invertido: boolean
49
+ }
50
+
51
+ export function resolverFondo(fondo: Fondo): EstiloFondo {
52
+ switch (fondo) {
53
+ case "oscuro":
54
+ return {
55
+ background: "var(--cms-texto,#151515)",
56
+ color: "var(--cms-fondo,#fafaf8)",
57
+ colorSuave: "color-mix(in srgb, var(--cms-fondo,#fafaf8) 68%, transparent)",
58
+ borde: "color-mix(in srgb, var(--cms-fondo,#fafaf8) 22%, transparent)",
59
+ acento: "currentColor",
60
+ invertido: true,
61
+ }
62
+ case "acento":
63
+ return {
64
+ background: "var(--cms-acento,#3b2bff)",
65
+ color: "var(--cms-primario-texto,#ffffff)",
66
+ colorSuave: "color-mix(in srgb, var(--cms-primario-texto,#ffffff) 78%, transparent)",
67
+ borde: "color-mix(in srgb, var(--cms-primario-texto,#ffffff) 28%, transparent)",
68
+ acento: "currentColor",
69
+ invertido: true,
70
+ }
71
+ case "superficie":
72
+ return {
73
+ background: "var(--cms-superficie,#ffffff)",
74
+ color: "var(--cms-texto,#151515)",
75
+ colorSuave: "var(--cms-texto-suave,#666666)",
76
+ borde: "var(--cms-borde,#e6e4df)",
77
+ acento: "var(--cms-acento,#3b2bff)",
78
+ invertido: false,
79
+ }
80
+ case "claro":
81
+ default:
82
+ return {
83
+ background: "var(--cms-fondo,#fafaf8)",
84
+ color: "var(--cms-texto,#151515)",
85
+ colorSuave: "var(--cms-texto-suave,#666666)",
86
+ borde: "var(--cms-borde,#e6e4df)",
87
+ acento: "var(--cms-acento,#3b2bff)",
88
+ invertido: false,
89
+ }
90
+ }
91
+ }
92
+
93
+ // ── Motion ───────────────────────────────────────────────────────────────────
94
+
95
+ /** Clase que dispara el reveal-on-scroll (ver `EstiloMotion`). */
96
+ export const CLASE_REVEAL = "cms-reveal"
97
+
98
+ const CSS_MOTION =
99
+ "@media (prefers-reduced-motion: no-preference){" +
100
+ "@supports (animation-timeline: view()){" +
101
+ ".cms-reveal{animation:cms-reveal-in linear both;animation-timeline:view();animation-range:entry 0% cover 32%}" +
102
+ "@keyframes cms-reveal-in{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}" +
103
+ "}}"
104
+
105
+ /**
106
+ * Emite (una vez por bloque) las reglas CSS del reveal-on-scroll. Se puede
107
+ * repetir sin daño si hay varios bloques en la página.
108
+ */
109
+ export function EstiloMotion() {
110
+ return <style dangerouslySetInnerHTML={{ __html: CSS_MOTION }} />
111
+ }
112
+
113
+ // ── Piezas compartidas ───────────────────────────────────────────────────────
114
+
115
+ /** Etiqueta/eyebrow monoespaciada; toma el acento del fondo activo. */
116
+ export function Eyebrow({ children, color }: { children: string; color: string }) {
117
+ if (!children) return null
118
+ return (
119
+ <div
120
+ className="text-[11px] font-semibold tracking-[0.16em]"
121
+ style={{ fontFamily: "var(--cms-fuente-mono)", color }}
122
+ >
123
+ {children}
124
+ </div>
125
+ )
126
+ }
127
+
128
+ /** Único heading de sección de los bloques nuevos: `<h2>` (el `<h1>` es del hero). */
129
+ export function TituloSeccion({ children, className }: { children: string; className?: string }) {
130
+ return (
131
+ <h2
132
+ className={`m-0 text-2xl leading-[1.15] md:text-[30px] ${className ?? ""}`}
133
+ style={{
134
+ fontFamily: "var(--cms-fuente-titulo)",
135
+ fontWeight: "var(--cms-titulo-peso)",
136
+ textTransform: "var(--cms-titulo-transform)",
137
+ letterSpacing: "var(--cms-titulo-espaciado)",
138
+ }}
139
+ >
140
+ {children}
141
+ </h2>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * Contenedor de sección: aplica el `fondo` resuelto + espaciado vertical
147
+ * coherente (`--cms-espaciado-seccion` con fallback) + radio del tema.
148
+ */
149
+ export function Seccion({
150
+ fondo,
151
+ children,
152
+ className,
153
+ ariaLabel,
154
+ }: {
155
+ fondo: Fondo
156
+ children: ReactNode
157
+ className?: string
158
+ ariaLabel?: string
159
+ }) {
160
+ const f = resolverFondo(fondo)
161
+ const style: CSSProperties = {
162
+ background: f.background,
163
+ color: f.color,
164
+ borderRadius: "var(--cms-radio)",
165
+ paddingTop: "var(--cms-espaciado-seccion, 3.5rem)",
166
+ paddingBottom: "var(--cms-espaciado-seccion, 3.5rem)",
167
+ }
168
+ return (
169
+ <section aria-label={ariaLabel} className={`w-full px-5 md:px-8 ${className ?? ""}`} style={style}>
170
+ <EstiloMotion />
171
+ {children}
172
+ </section>
173
+ )
174
+ }
@@ -0,0 +1,109 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"
4
+
5
+ /**
6
+ * Slider horizontal con flechas prev/next, scrollbar OCULTO y snap. Sustituye
7
+ * al `overflow-x-auto` con barra visible (poco elegante) por un carrusel
8
+ * moderno: en desktop se navega con las flechas, en touch se desliza con el
9
+ * dedo. Recibe los `<li>` ya renderizados como `children` (patrón RSC: un
10
+ * server component pasa hijos a este client component).
11
+ *
12
+ * Los colores de las flechas se pasan desde el bloque (según su `fondo`
13
+ * resuelto) para que sean legibles sobre cualquier superficie.
14
+ */
15
+ export function Carrusel({
16
+ children,
17
+ ariaLabel,
18
+ colorFlecha,
19
+ bordeFlecha,
20
+ fondoFlecha,
21
+ }: {
22
+ children: ReactNode
23
+ ariaLabel?: string
24
+ colorFlecha: string
25
+ bordeFlecha: string
26
+ fondoFlecha: string
27
+ }) {
28
+ const ref = useRef<HTMLUListElement>(null)
29
+ const [puedeIzq, setPuedeIzq] = useState(false)
30
+ const [puedeDer, setPuedeDer] = useState(false)
31
+
32
+ const actualizar = useCallback(() => {
33
+ const el = ref.current
34
+ if (!el) return
35
+ setPuedeIzq(el.scrollLeft > 4)
36
+ setPuedeDer(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
37
+ }, [])
38
+
39
+ useEffect(() => {
40
+ const el = ref.current
41
+ if (!el) return
42
+ actualizar()
43
+ el.addEventListener("scroll", actualizar, { passive: true })
44
+ window.addEventListener("resize", actualizar)
45
+ return () => {
46
+ el.removeEventListener("scroll", actualizar)
47
+ window.removeEventListener("resize", actualizar)
48
+ }
49
+ }, [actualizar])
50
+
51
+ const desplazar = (dir: -1 | 1) => {
52
+ const el = ref.current
53
+ if (!el) return
54
+ const suave =
55
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
56
+ ? !window.matchMedia("(prefers-reduced-motion: reduce)").matches
57
+ : true
58
+ const paso = Math.max(el.clientWidth * 0.9, 240)
59
+ el.scrollBy({ left: dir * paso, behavior: suave ? "smooth" : "auto" })
60
+ }
61
+
62
+ const estiloFlecha = {
63
+ color: colorFlecha,
64
+ borderColor: bordeFlecha,
65
+ background: fondoFlecha,
66
+ }
67
+ const claseFlecha =
68
+ "absolute top-1/2 z-10 hidden h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm transition-opacity duration-200 md:flex"
69
+
70
+ return (
71
+ <div className="relative">
72
+ <ul
73
+ ref={ref}
74
+ aria-label={ariaLabel}
75
+ className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
76
+ >
77
+ {children}
78
+ </ul>
79
+
80
+ <button
81
+ type="button"
82
+ aria-label="Anterior"
83
+ onClick={() => desplazar(-1)}
84
+ disabled={!puedeIzq}
85
+ aria-hidden={!puedeIzq}
86
+ className={`${claseFlecha} left-1 ${puedeIzq ? "opacity-100" : "pointer-events-none opacity-0"}`}
87
+ style={estiloFlecha}
88
+ >
89
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
90
+ <path d="M15 5l-7 7 7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
91
+ </svg>
92
+ </button>
93
+
94
+ <button
95
+ type="button"
96
+ aria-label="Siguiente"
97
+ onClick={() => desplazar(1)}
98
+ disabled={!puedeDer}
99
+ aria-hidden={!puedeDer}
100
+ className={`${claseFlecha} right-1 ${puedeDer ? "opacity-100" : "pointer-events-none opacity-0"}`}
101
+ style={estiloFlecha}
102
+ >
103
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
104
+ <path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
105
+ </svg>
106
+ </button>
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,59 @@
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 { categoryTilesBlock } from "./category-tiles"
6
+ import { mockCtx } from "../test/mock-ctx"
7
+
8
+ describe("categoryTilesBlock", () => {
9
+ it("defaults pasan el schema", () => {
10
+ expect(categoryTilesBlock.schema.safeParse(categoryTilesBlock.defaults).success).toBe(true)
11
+ })
12
+
13
+ it("renderiza un enlace por item con su titulo", () => {
14
+ const html = renderToStaticMarkup(
15
+ <categoryTilesBlock.Component props={categoryTilesBlock.defaults} ctx={mockCtx()} />
16
+ )
17
+
18
+ for (const item of categoryTilesBlock.defaults.items) {
19
+ expect(html).toContain(`href="${item.href}"`)
20
+ expect(html).toContain(item.titulo)
21
+ }
22
+ })
23
+
24
+ it.each([3, 4] as const)("renderiza sin crash con columnas: %i (md:grid-cols-%i)", (columnas) => {
25
+ const props = { ...categoryTilesBlock.defaults, columnas }
26
+ expect(categoryTilesBlock.schema.safeParse(props).success).toBe(true)
27
+
28
+ const html = renderToStaticMarkup(<categoryTilesBlock.Component props={props} ctx={mockCtx()} />)
29
+
30
+ expect(html).toContain(`md:grid-cols-${columnas}`)
31
+ expect(html).toContain("grid-cols-2")
32
+ })
33
+
34
+ it("con imagenUrl vacío usa el fallback del mock Imagen en vez de <img src=\"\">", () => {
35
+ const props = {
36
+ ...categoryTilesBlock.defaults,
37
+ items: [{ titulo: "Sin imagen", imagenUrl: "", href: "/categories/sin-imagen" }],
38
+ }
39
+ const html = renderToStaticMarkup(<categoryTilesBlock.Component props={props} ctx={mockCtx()} />)
40
+
41
+ expect(html).not.toContain('src=""')
42
+ expect(html).toContain('role="img"')
43
+ })
44
+
45
+ it("no usa <h1> (ese heading es exclusivo del hero)", () => {
46
+ const html = renderToStaticMarkup(
47
+ <categoryTilesBlock.Component props={categoryTilesBlock.defaults} ctx={mockCtx()} />
48
+ )
49
+ expect(html).not.toMatch(/<h1[ >]/)
50
+ })
51
+
52
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
53
+ const rutaFuente = fileURLToPath(new URL("./category-tiles.tsx", import.meta.url))
54
+ const fuente = readFileSync(rutaFuente, "utf-8")
55
+
56
+ expect(fuente).not.toMatch(/<img[\s>]/)
57
+ expect(fuente).not.toMatch(/<a[\s>]/)
58
+ })
59
+ })
@@ -0,0 +1,70 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import type { RenderContext } from "../context"
4
+
5
+ export type CategoryTileItem = {
6
+ titulo: string
7
+ imagenUrl: string
8
+ href: string
9
+ }
10
+
11
+ export type CategoryTilesProps = {
12
+ columnas: 3 | 4
13
+ items: CategoryTileItem[]
14
+ }
15
+
16
+ const categoryTileItemSchema: z.ZodType<CategoryTileItem> = z.object({
17
+ titulo: z.string().min(1),
18
+ imagenUrl: z.string(),
19
+ href: z.string().min(1),
20
+ })
21
+
22
+ const categoryTilesSchema: z.ZodType<CategoryTilesProps> = z.object({
23
+ columnas: z.union([z.literal(3), z.literal(4)]),
24
+ items: z.array(categoryTileItemSchema),
25
+ })
26
+
27
+ const categoryTilesDefaults: CategoryTilesProps = {
28
+ columnas: 3,
29
+ items: [
30
+ { titulo: "Mujer", imagenUrl: "", href: "/categories/mujer" },
31
+ { titulo: "Hombre", imagenUrl: "", href: "/categories/hombre" },
32
+ { titulo: "Accesorios", imagenUrl: "", href: "/categories/accesorios" },
33
+ ],
34
+ }
35
+
36
+ /** Mosaico individual: la imagen reserva alto fijo (CLS 0), el título va superpuesto. */
37
+ function Tile({ item, ctx }: { item: CategoryTileItem; ctx: RenderContext }) {
38
+ const { Imagen, Enlace } = ctx.componentes
39
+ return (
40
+ <Enlace
41
+ href={item.href}
42
+ className="relative block h-[180px] overflow-hidden [border-radius:var(--cms-radio)] md:h-[230px]"
43
+ >
44
+ <Imagen
45
+ src={item.imagenUrl}
46
+ alt={item.titulo}
47
+ className="absolute inset-0 h-full w-full object-cover"
48
+ />
49
+ <span className="absolute inset-x-0 bottom-0 px-3.5 pb-3 pt-8 text-xs font-semibold uppercase tracking-[0.06em] text-white [background:linear-gradient(transparent,rgba(0,0,0,.6))] [font-family:var(--cms-fuente-texto)] md:px-4 md:pb-3.5 md:pt-10 md:text-sm">
50
+ {item.titulo}
51
+ </span>
52
+ </Enlace>
53
+ )
54
+ }
55
+
56
+ export const categoryTilesBlock: BlockDefinition<CategoryTilesProps, RenderContext> = {
57
+ label: "Mosaicos de categoría",
58
+ schema: categoryTilesSchema,
59
+ defaults: categoryTilesDefaults,
60
+ Component: ({ props, ctx }) => {
61
+ const colsMdClass = props.columnas === 4 ? "md:grid-cols-4" : "md:grid-cols-3"
62
+ return (
63
+ <div className={`grid w-full grid-cols-2 gap-3 md:gap-3.5 ${colsMdClass}`}>
64
+ {props.items.map((item) => (
65
+ <Tile key={item.href} item={item} ctx={ctx} />
66
+ ))}
67
+ </div>
68
+ )
69
+ },
70
+ }
@@ -0,0 +1,96 @@
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 { editorialBannerBlock } from "./editorial-banner"
6
+ import { FONDOS, resolverFondo } from "./_shared"
7
+ import { mockCtx } from "../test/mock-ctx"
8
+
9
+ const posiciones = ["derecha", "izquierda", "fondo"] as const
10
+
11
+ describe("editorialBannerBlock", () => {
12
+ it.each(posiciones)(
13
+ "renderiza sin crash la posicionImagen %s con titulo, texto y enlace",
14
+ (posicionImagen) => {
15
+ const props = { ...editorialBannerBlock.defaults, posicionImagen }
16
+ expect(editorialBannerBlock.schema.safeParse(props).success).toBe(true)
17
+
18
+ const html = renderToStaticMarkup(
19
+ <editorialBannerBlock.Component props={props} ctx={mockCtx()} />
20
+ )
21
+
22
+ expect(html).toContain(editorialBannerBlock.defaults.titulo)
23
+ expect(html).toContain(editorialBannerBlock.defaults.texto)
24
+ expect(html).toContain(editorialBannerBlock.defaults.enlace.texto)
25
+ expect(html).toContain(`href="${editorialBannerBlock.defaults.enlace.href}"`)
26
+ }
27
+ )
28
+
29
+ it("con imagenUrl vacío usa el fallback del mock Imagen en vez de <img src=\"\">", () => {
30
+ const props = { ...editorialBannerBlock.defaults, imagenUrl: "" }
31
+ const html = renderToStaticMarkup(
32
+ <editorialBannerBlock.Component props={props} ctx={mockCtx()} />
33
+ )
34
+
35
+ expect(html).not.toContain('src=""')
36
+ expect(html).toContain('role="img"')
37
+ })
38
+
39
+ it("defaults pasan el schema", () => {
40
+ expect(editorialBannerBlock.schema.safeParse(editorialBannerBlock.defaults).success).toBe(true)
41
+ })
42
+
43
+ it("el fondo por defecto es 'superficie' (preserva el look actual)", () => {
44
+ expect(editorialBannerBlock.defaults.fondo).toBe("superficie")
45
+ })
46
+
47
+ it.each(FONDOS)("cualquier fondo (%s) pasa el schema", (fondo) => {
48
+ expect(editorialBannerBlock.schema.safeParse({ ...editorialBannerBlock.defaults, fondo }).success).toBe(true)
49
+ })
50
+
51
+ it.each(["derecha", "izquierda"] as const)(
52
+ "la posición %s aplica el fondo elegido al panel",
53
+ (posicionImagen) => {
54
+ const oscuro = resolverFondo("oscuro")
55
+ const html = renderToStaticMarkup(
56
+ <editorialBannerBlock.Component
57
+ props={{ ...editorialBannerBlock.defaults, posicionImagen, fondo: "oscuro" }}
58
+ ctx={mockCtx()}
59
+ />
60
+ )
61
+ expect(html).toContain(oscuro.background)
62
+ }
63
+ )
64
+
65
+ it("posición 'fondo' la manda la imagen: el fondo no altera su markup", () => {
66
+ const superficie = renderToStaticMarkup(
67
+ <editorialBannerBlock.Component
68
+ props={{ ...editorialBannerBlock.defaults, posicionImagen: "fondo", fondo: "superficie" }}
69
+ ctx={mockCtx()}
70
+ />
71
+ )
72
+ const oscuro = renderToStaticMarkup(
73
+ <editorialBannerBlock.Component
74
+ props={{ ...editorialBannerBlock.defaults, posicionImagen: "fondo", fondo: "oscuro" }}
75
+ ctx={mockCtx()}
76
+ />
77
+ )
78
+ expect(oscuro).toBe(superficie)
79
+ })
80
+
81
+ it("usa un único <h2> como título de sección (nunca <h1>)", () => {
82
+ const html = renderToStaticMarkup(
83
+ <editorialBannerBlock.Component props={editorialBannerBlock.defaults} ctx={mockCtx()} />
84
+ )
85
+ expect((html.match(/<h2[ >]/g) ?? []).length).toBe(1)
86
+ expect(html).not.toMatch(/<h1[ >]/)
87
+ })
88
+
89
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
90
+ const rutaFuente = fileURLToPath(new URL("./editorial-banner.tsx", import.meta.url))
91
+ const fuente = readFileSync(rutaFuente, "utf-8")
92
+
93
+ expect(fuente).not.toMatch(/<img[\s>]/)
94
+ expect(fuente).not.toMatch(/<a[\s>]/)
95
+ })
96
+ })