@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,75 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+
5
+ /** Descompone un intervalo (ms) en días/horas/min/seg de dos dígitos. */
6
+ function partes(ms: number): { d: string; h: string; m: string; s: string } {
7
+ const total = Math.max(0, Math.floor(ms / 1000))
8
+ const dd = (n: number) => String(n).padStart(2, "0")
9
+ return {
10
+ d: dd(Math.floor(total / 86400)),
11
+ h: dd(Math.floor((total % 86400) / 3600)),
12
+ m: dd(Math.floor((total % 3600) / 60)),
13
+ s: dd(total % 60),
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Contador regresivo SSR-safe. Antes de montar (servidor + primer render del
19
+ * cliente) `restante` es null y se pintan guiones; el intervalo arranca en un
20
+ * efecto, así el markup inicial es idéntico en ambos lados.
21
+ *
22
+ * Vive en su propio módulo `"use client"` para que `promo.tsx` (que exporta
23
+ * constantes como `PROMO_FONDOS` y el block def, consumidos por `puck-config`
24
+ * en contexto servidor/RSC) NO sea un módulo cliente — si lo fuera, esos exports
25
+ * se volverían referencias de cliente y romperían el eval del servidor.
26
+ */
27
+ export function Contador({
28
+ objetivo,
29
+ color,
30
+ colorSuave,
31
+ }: {
32
+ objetivo: number
33
+ color: string
34
+ colorSuave: string
35
+ }) {
36
+ const [restante, setRestante] = useState<number | null>(null)
37
+
38
+ useEffect(() => {
39
+ const tick = () => setRestante(Math.max(0, objetivo - Date.now()))
40
+ tick()
41
+ const id = setInterval(tick, 1000)
42
+ return () => clearInterval(id)
43
+ }, [objetivo])
44
+
45
+ const p = restante === null ? null : partes(restante)
46
+ const segmentos: { valor: string; etiqueta: string }[] = [
47
+ { valor: p?.d ?? "––", etiqueta: "días" },
48
+ { valor: p?.h ?? "––", etiqueta: "hrs" },
49
+ { valor: p?.m ?? "––", etiqueta: "min" },
50
+ { valor: p?.s ?? "––", etiqueta: "seg" },
51
+ ]
52
+
53
+ return (
54
+ <div
55
+ className="flex items-center gap-2.5"
56
+ role="timer"
57
+ aria-label="Tiempo restante de la promoción"
58
+ style={{ color }}
59
+ >
60
+ {segmentos.map((seg, i) => (
61
+ <div key={i} className="flex flex-col items-center">
62
+ <span
63
+ className="text-lg font-bold tabular-nums leading-none md:text-xl"
64
+ style={{ fontFamily: "var(--cms-fuente-mono)" }}
65
+ >
66
+ {seg.valor}
67
+ </span>
68
+ <span className="mt-1 text-[10px] uppercase tracking-[0.12em]" style={{ color: colorSuave }}>
69
+ {seg.etiqueta}
70
+ </span>
71
+ </div>
72
+ ))}
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { promoBlock, PROMO_FONDOS } from "./promo"
4
+ import { mockCtx } from "../test/mock-ctx"
5
+
6
+ describe("promoBlock", () => {
7
+ it("defaults pasan el schema", () => {
8
+ expect(promoBlock.schema.safeParse(promoBlock.defaults).success).toBe(true)
9
+ })
10
+
11
+ it("incluye 'degradado' entre los fondos", () => {
12
+ expect(PROMO_FONDOS).toContain("degradado")
13
+ })
14
+
15
+ it.each(PROMO_FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
16
+ const props = { ...promoBlock.defaults, fondo }
17
+ expect(promoBlock.schema.safeParse(props).success).toBe(true)
18
+ const html = renderToStaticMarkup(<promoBlock.Component props={props} ctx={mockCtx()} />)
19
+ expect(html).toContain(promoBlock.defaults.titulo)
20
+ })
21
+
22
+ it("sin fecha de cuenta regresiva NO muestra el contador", () => {
23
+ const html = renderToStaticMarkup(
24
+ <promoBlock.Component props={{ ...promoBlock.defaults, cuentaRegresiva: "" }} ctx={mockCtx()} />
25
+ )
26
+ expect(html).not.toContain('role="timer"')
27
+ })
28
+
29
+ it("con fecha ISO válida muestra el contador (SSR-safe: render inicial estable con guiones)", () => {
30
+ const futuro = new Date(Date.now() + 3 * 86400_000).toISOString()
31
+ const html = renderToStaticMarkup(
32
+ <promoBlock.Component props={{ ...promoBlock.defaults, cuentaRegresiva: futuro }} ctx={mockCtx()} />
33
+ )
34
+ expect(html).toContain('role="timer"')
35
+ // Render inicial (pre-mount) muestra guiones deterministas, no números de Date.now().
36
+ expect(html).toContain("––")
37
+ })
38
+
39
+ it("con fecha inválida NO muestra el contador", () => {
40
+ const html = renderToStaticMarkup(
41
+ <promoBlock.Component props={{ ...promoBlock.defaults, cuentaRegresiva: "no-es-fecha" }} ctx={mockCtx()} />
42
+ )
43
+ expect(html).not.toContain('role="timer"')
44
+ })
45
+
46
+ it("texto opcional: sin texto no rompe", () => {
47
+ const props = { fondo: "acento" as const, titulo: "T", enlace: { texto: "Ir", href: "/x" } }
48
+ expect(promoBlock.schema.safeParse(props).success).toBe(true)
49
+ const html = renderToStaticMarkup(<promoBlock.Component props={props} ctx={mockCtx()} />)
50
+ expect(html).toContain("T")
51
+ })
52
+
53
+ it("renderiza el enlace con su href", () => {
54
+ const html = renderToStaticMarkup(<promoBlock.Component props={promoBlock.defaults} ctx={mockCtx()} />)
55
+ expect(html).toContain(`href="${promoBlock.defaults.enlace.href}"`)
56
+ expect(html).toContain(promoBlock.defaults.enlace.texto)
57
+ })
58
+
59
+ it("emite motion respetando prefers-reduced-motion, sin <h1>", () => {
60
+ const html = renderToStaticMarkup(<promoBlock.Component props={promoBlock.defaults} ctx={mockCtx()} />)
61
+ expect(html).toContain("prefers-reduced-motion")
62
+ expect(html).toContain("cms-reveal")
63
+ expect(html).not.toMatch(/<h1[ >]/)
64
+ })
65
+
66
+ it("titulo vacío no pasa el schema", () => {
67
+ expect(
68
+ promoBlock.schema.safeParse({ ...promoBlock.defaults, titulo: "" }).success
69
+ ).toBe(false)
70
+ })
71
+ })
@@ -0,0 +1,120 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import { linkSchema } from "@arroyavecommerce/cms-core"
4
+ import type { RenderContext } from "../context"
5
+ import { CLASE_REVEAL, EstiloMotion, resolverFondo, FONDOS, type Fondo } from "./_shared"
6
+ import { Contador } from "./promo-countdown"
7
+
8
+ /**
9
+ * Banda promocional: título + texto + enlace, con un contador regresivo
10
+ * opcional. Extiende el enum `fondo` con `degradado` (la banda ES el color).
11
+ *
12
+ * Este módulo es server-safe (NO `"use client"`): sus exports (`PROMO_FONDOS`,
13
+ * `promoBlock`) los consume `puck-config` en contexto servidor/RSC. La única
14
+ * parte cliente (el contador con hooks) vive en `./promo-countdown`.
15
+ */
16
+
17
+ export const PROMO_FONDOS = [...FONDOS, "degradado"] as const
18
+ export type PromoFondo = (typeof PROMO_FONDOS)[number]
19
+
20
+ export type PromoProps = {
21
+ fondo: PromoFondo
22
+ titulo: string
23
+ texto?: string
24
+ enlace: { texto: string; href: string }
25
+ /** Fecha ISO (opcional). Si viene vacía o inválida, no se muestra el contador. */
26
+ cuentaRegresiva?: string
27
+ }
28
+
29
+ const promoSchema: z.ZodType<PromoProps> = z.object({
30
+ fondo: z.enum(PROMO_FONDOS),
31
+ titulo: z.string().min(1),
32
+ texto: z.string().optional(),
33
+ enlace: linkSchema,
34
+ cuentaRegresiva: z.string().optional(),
35
+ })
36
+
37
+ const promoDefaults: PromoProps = {
38
+ fondo: "degradado",
39
+ titulo: "Rebajas de temporada: hasta 40% off",
40
+ texto: "Solo por tiempo limitado en prendas seleccionadas.",
41
+ enlace: { texto: "Ver ofertas", href: "/collections/sale" },
42
+ cuentaRegresiva: "",
43
+ }
44
+
45
+ type EstiloBanda = { background: string; color: string; colorSuave: string; borde: string; acento: string }
46
+
47
+ function resolverFondoPromo(fondo: PromoFondo): EstiloBanda {
48
+ if (fondo === "degradado") {
49
+ return {
50
+ background: "var(--cms-degradado,linear-gradient(120deg,#3b2bff,#ff3b2e))",
51
+ color: "var(--cms-primario-texto,#ffffff)",
52
+ colorSuave: "color-mix(in srgb, var(--cms-primario-texto,#ffffff) 82%, transparent)",
53
+ borde: "color-mix(in srgb, var(--cms-primario-texto,#ffffff) 32%, transparent)",
54
+ acento: "currentColor",
55
+ }
56
+ }
57
+ const f = resolverFondo(fondo as Fondo)
58
+ return { background: f.background, color: f.color, colorSuave: f.colorSuave, borde: f.borde, acento: f.acento }
59
+ }
60
+
61
+ /** Devuelve el timestamp objetivo si la fecha ISO es válida; si no, null. */
62
+ function objetivoValido(iso?: string): number | null {
63
+ if (!iso) return null
64
+ const t = Date.parse(iso)
65
+ return Number.isNaN(t) ? null : t
66
+ }
67
+
68
+ export const promoBlock: BlockDefinition<PromoProps, RenderContext> = {
69
+ label: "Banda promocional",
70
+ schema: promoSchema,
71
+ defaults: promoDefaults,
72
+ Component: ({ props, ctx }) => {
73
+ const b = resolverFondoPromo(props.fondo)
74
+ const { Enlace } = ctx.componentes
75
+ const objetivo = objetivoValido(props.cuentaRegresiva)
76
+ return (
77
+ <section
78
+ aria-label={props.titulo}
79
+ className="w-full px-5 py-8 md:px-8 md:py-10"
80
+ style={{ background: b.background, color: b.color, borderRadius: "var(--cms-radio)" }}
81
+ >
82
+ <EstiloMotion />
83
+ <div
84
+ className={`${CLASE_REVEAL} mx-auto flex w-full max-w-6xl flex-col items-start gap-5 md:flex-row md:items-center md:justify-between`}
85
+ >
86
+ <div className="flex flex-col gap-1.5">
87
+ <h2
88
+ className="m-0 text-xl leading-tight md:text-2xl"
89
+ style={{
90
+ fontFamily: "var(--cms-fuente-titulo)",
91
+ fontWeight: "var(--cms-titulo-peso)",
92
+ textTransform: "var(--cms-titulo-transform)",
93
+ letterSpacing: "var(--cms-titulo-espaciado)",
94
+ }}
95
+ >
96
+ {props.titulo}
97
+ </h2>
98
+ {props.texto ? (
99
+ <p className="m-0 text-[14px] leading-relaxed" style={{ color: b.colorSuave }}>
100
+ {props.texto}
101
+ </p>
102
+ ) : null}
103
+ </div>
104
+
105
+ <div className="flex flex-wrap items-center gap-5">
106
+ {objetivo !== null ? <Contador objetivo={objetivo} color={b.color} colorSuave={b.colorSuave} /> : null}
107
+ {props.enlace.texto ? (
108
+ <Enlace
109
+ href={props.enlace.href}
110
+ className="inline-block whitespace-nowrap px-[22px] py-[11px] text-center text-[11.5px] font-semibold uppercase tracking-[0.08em] [border:1.5px_solid_currentColor] [border-radius:var(--cms-radio)] [font-family:var(--cms-fuente-texto)]"
111
+ >
112
+ {props.enlace.texto}
113
+ </Enlace>
114
+ ) : null}
115
+ </div>
116
+ </div>
117
+ </section>
118
+ )
119
+ },
120
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { richTextBlock, ALINEACIONES } from "./rich-text"
4
+ import { FONDOS } from "./_shared"
5
+ import { mockCtx } from "../test/mock-ctx"
6
+
7
+ describe("richTextBlock", () => {
8
+ it("defaults pasan el schema", () => {
9
+ expect(richTextBlock.schema.safeParse(richTextBlock.defaults).success).toBe(true)
10
+ })
11
+
12
+ it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
13
+ const props = { ...richTextBlock.defaults, fondo }
14
+ expect(richTextBlock.schema.safeParse(props).success).toBe(true)
15
+ const html = renderToStaticMarkup(<richTextBlock.Component props={props} ctx={mockCtx()} />)
16
+ expect(html).toContain("<section")
17
+ })
18
+
19
+ it("renderiza un <p> por línea del cuerpo (saltos de línea → párrafos)", () => {
20
+ const props = {
21
+ fondo: "claro" as const,
22
+ alineacion: "izquierda" as const,
23
+ cuerpo: "Uno\nDos\nTres",
24
+ }
25
+ const html = renderToStaticMarkup(<richTextBlock.Component props={props} ctx={mockCtx()} />)
26
+ expect((html.match(/<p /g) ?? []).length).toBe(3)
27
+ expect(html).toContain("Uno")
28
+ expect(html).toContain("Dos")
29
+ expect(html).toContain("Tres")
30
+ })
31
+
32
+ it("descarta líneas vacías entre párrafos", () => {
33
+ const props = {
34
+ fondo: "claro" as const,
35
+ alineacion: "izquierda" as const,
36
+ cuerpo: "Uno\n\n\nDos",
37
+ }
38
+ const html = renderToStaticMarkup(<richTextBlock.Component props={props} ctx={mockCtx()} />)
39
+ expect((html.match(/<p /g) ?? []).length).toBe(2)
40
+ })
41
+
42
+ it.each(ALINEACIONES)("aplica la alineación %s", (alineacion) => {
43
+ const props = { ...richTextBlock.defaults, alineacion }
44
+ const html = renderToStaticMarkup(<richTextBlock.Component props={props} ctx={mockCtx()} />)
45
+ expect(html).toContain(alineacion === "centro" ? "text-center" : "text-left")
46
+ })
47
+
48
+ it("limita el ancho de lectura (~65ch)", () => {
49
+ const html = renderToStaticMarkup(<richTextBlock.Component props={richTextBlock.defaults} ctx={mockCtx()} />)
50
+ expect(html).toContain("max-w-[65ch]")
51
+ })
52
+
53
+ it("titulo opcional: sin titulo no pinta <h2>", () => {
54
+ const html = renderToStaticMarkup(
55
+ <richTextBlock.Component
56
+ props={{ fondo: "claro", alineacion: "izquierda", cuerpo: "Solo cuerpo" }}
57
+ ctx={mockCtx()}
58
+ />
59
+ )
60
+ expect(html).not.toContain("<h2")
61
+ })
62
+
63
+ it("no usa <h1>", () => {
64
+ const html = renderToStaticMarkup(<richTextBlock.Component props={richTextBlock.defaults} ctx={mockCtx()} />)
65
+ expect(html).not.toMatch(/<h1[ >]/)
66
+ })
67
+ })
@@ -0,0 +1,75 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import type { RenderContext } from "../context"
4
+ import { Seccion, TituloSeccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
5
+
6
+ /**
7
+ * Texto libre: un título opcional + cuerpo editable (textarea). Los saltos de
8
+ * línea del cuerpo se renderizan como párrafos separados y el ancho se limita a
9
+ * ~65ch para una lectura cómoda. No va en el home por defecto: queda disponible
10
+ * en Puck para páginas de contenido (Sobre nosotros, políticas, etc.).
11
+ */
12
+
13
+ export const ALINEACIONES = ["izquierda", "centro"] as const
14
+ export type Alineacion = (typeof ALINEACIONES)[number]
15
+
16
+ export type RichTextProps = {
17
+ fondo: Fondo
18
+ alineacion: Alineacion
19
+ titulo?: string
20
+ cuerpo: string
21
+ }
22
+
23
+ const richTextSchema: z.ZodType<RichTextProps> = z.object({
24
+ fondo: z.enum(FONDOS),
25
+ alineacion: z.enum(ALINEACIONES),
26
+ titulo: z.string().optional(),
27
+ cuerpo: z.string(),
28
+ })
29
+
30
+ const richTextDefaults: RichTextProps = {
31
+ fondo: "claro",
32
+ alineacion: "izquierda",
33
+ titulo: "Nuestra historia",
34
+ cuerpo:
35
+ "Somos una marca independiente de moda urbana nacida en Colombia.\n" +
36
+ "Diseñamos prendas de tirada corta con materiales que duran, pensadas para el día a día.\n" +
37
+ "Cada drop cuenta una historia y se produce en cantidades limitadas.",
38
+ }
39
+
40
+ /** Divide el cuerpo en párrafos por saltos de línea, descartando líneas vacías. */
41
+ function aParrafos(cuerpo: string): string[] {
42
+ return cuerpo
43
+ .split(/\n+/)
44
+ .map((linea) => linea.trim())
45
+ .filter((linea) => linea.length > 0)
46
+ }
47
+
48
+ export const richTextBlock: BlockDefinition<RichTextProps, RenderContext> = {
49
+ label: "Texto libre",
50
+ schema: richTextSchema,
51
+ defaults: richTextDefaults,
52
+ Component: ({ props }) => {
53
+ const f = resolverFondo(props.fondo)
54
+ const parrafos = aParrafos(props.cuerpo)
55
+ const centrado = props.alineacion === "centro"
56
+ return (
57
+ <Seccion fondo={props.fondo} ariaLabel={props.titulo || "Texto"}>
58
+ <div className={`mx-auto w-full max-w-[65ch] ${centrado ? "text-center" : "text-left"}`}>
59
+ {props.titulo ? <TituloSeccion className={`mb-5 ${centrado ? "text-center" : ""}`}>{props.titulo}</TituloSeccion> : null}
60
+ <div className="flex flex-col gap-4">
61
+ {parrafos.map((parrafo, i) => (
62
+ <p
63
+ key={i}
64
+ className="m-0 text-[15px] leading-relaxed md:text-base"
65
+ style={{ fontFamily: "var(--cms-fuente-texto)", color: f.color }}
66
+ >
67
+ {parrafo}
68
+ </p>
69
+ ))}
70
+ </div>
71
+ </div>
72
+ </Seccion>
73
+ )
74
+ },
75
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { spacerBlock, TAMANOS_SPACER } from "./spacer"
4
+ import { mockCtx } from "../test/mock-ctx"
5
+
6
+ describe("spacerBlock", () => {
7
+ it("defaults pasan el schema", () => {
8
+ expect(spacerBlock.schema.safeParse(spacerBlock.defaults).success).toBe(true)
9
+ })
10
+
11
+ it.each(TAMANOS_SPACER)("renderiza sin crash con tamaño %s", (tamano) => {
12
+ const props = { ...spacerBlock.defaults, tamano }
13
+ expect(spacerBlock.schema.safeParse(props).success).toBe(true)
14
+ const html = renderToStaticMarkup(<spacerBlock.Component props={props} ctx={mockCtx()} />)
15
+ expect(html).toContain("height:")
16
+ })
17
+
18
+ it("cada tamaño produce una altura distinta", () => {
19
+ const alturas = TAMANOS_SPACER.map((tamano) => {
20
+ const html = renderToStaticMarkup(
21
+ <spacerBlock.Component props={{ tamano, divisor: false }} ctx={mockCtx()} />
22
+ )
23
+ return html.match(/height:([^;"]+)/)?.[1]
24
+ })
25
+ expect(new Set(alturas).size).toBe(TAMANOS_SPACER.length)
26
+ })
27
+
28
+ it("sin divisor no pinta línea", () => {
29
+ const html = renderToStaticMarkup(
30
+ <spacerBlock.Component props={{ tamano: "md", divisor: false }} ctx={mockCtx()} />
31
+ )
32
+ expect(html).not.toContain("<hr")
33
+ })
34
+
35
+ it("con divisor pinta una línea con var(--cms-borde)", () => {
36
+ const html = renderToStaticMarkup(
37
+ <spacerBlock.Component props={{ tamano: "md", divisor: true }} ctx={mockCtx()} />
38
+ )
39
+ expect(html).toContain("<hr")
40
+ expect(html).toContain("var(--cms-borde")
41
+ })
42
+
43
+ it("es decorativo (aria-hidden) y nunca usa <h1>", () => {
44
+ const html = renderToStaticMarkup(
45
+ <spacerBlock.Component props={{ tamano: "lg", divisor: true }} ctx={mockCtx()} />
46
+ )
47
+ expect(html).toContain('aria-hidden="true"')
48
+ expect(html).not.toMatch(/<h1[ >]/)
49
+ })
50
+
51
+ it("tamano inválido no pasa el schema", () => {
52
+ expect(spacerBlock.schema.safeParse({ tamano: "gigante", divisor: false }).success).toBe(false)
53
+ })
54
+ })
@@ -0,0 +1,56 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import type { RenderContext } from "../context"
4
+
5
+ /**
6
+ * Control de aire: inserta espacio vertical entre bloques, con un divisor
7
+ * opcional (línea `var(--cms-borde)`). No usa el enum `fondo` porque no pinta
8
+ * superficie: es puro espaciado. No va en el home por defecto.
9
+ */
10
+
11
+ export const TAMANOS_SPACER = ["xs", "sm", "md", "lg", "xl"] as const
12
+ export type TamanoSpacer = (typeof TAMANOS_SPACER)[number]
13
+
14
+ /** Altura por tamaño. `md` toma el token de sección del tema (con fallback). */
15
+ const ALTURA: Record<TamanoSpacer, string> = {
16
+ xs: "1rem",
17
+ sm: "2rem",
18
+ md: "var(--cms-espaciado-seccion, 3.5rem)",
19
+ lg: "5rem",
20
+ xl: "8rem",
21
+ }
22
+
23
+ export type SpacerProps = {
24
+ tamano: TamanoSpacer
25
+ divisor: boolean
26
+ }
27
+
28
+ const spacerSchema: z.ZodType<SpacerProps> = z.object({
29
+ tamano: z.enum(TAMANOS_SPACER),
30
+ divisor: z.boolean(),
31
+ })
32
+
33
+ const spacerDefaults: SpacerProps = {
34
+ tamano: "md",
35
+ divisor: false,
36
+ }
37
+
38
+ export const spacerBlock: BlockDefinition<SpacerProps, RenderContext> = {
39
+ label: "Espaciador",
40
+ schema: spacerSchema,
41
+ defaults: spacerDefaults,
42
+ Component: ({ props }) => {
43
+ const altura = ALTURA[props.tamano]
44
+ if (props.divisor) {
45
+ return (
46
+ <div className="flex w-full items-center" style={{ height: altura }} aria-hidden="true">
47
+ <hr
48
+ className="w-full border-0"
49
+ style={{ borderTop: `var(--cms-grosor-borde, 1px) solid var(--cms-borde, #e6e4df)` }}
50
+ />
51
+ </div>
52
+ )
53
+ }
54
+ return <div className="w-full" style={{ height: altura }} aria-hidden="true" />
55
+ },
56
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { testimonialsBlock } from "./testimonials"
4
+ import { FONDOS } from "./_shared"
5
+ import { mockCtx } from "../test/mock-ctx"
6
+
7
+ describe("testimonialsBlock", () => {
8
+ it("defaults pasan el schema", () => {
9
+ expect(testimonialsBlock.schema.safeParse(testimonialsBlock.defaults).success).toBe(true)
10
+ })
11
+
12
+ it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
13
+ const props = { ...testimonialsBlock.defaults, fondo }
14
+ expect(testimonialsBlock.schema.safeParse(props).success).toBe(true)
15
+ const html = renderToStaticMarkup(<testimonialsBlock.Component props={props} ctx={mockCtx()} />)
16
+ for (const item of testimonialsBlock.defaults.items) {
17
+ expect(html).toContain(item.autor)
18
+ expect(html).toContain(item.cita)
19
+ }
20
+ })
21
+
22
+ it("renderiza una tarjeta por item (editable)", () => {
23
+ const props = {
24
+ fondo: "claro" as const,
25
+ titulo: "Reseñas",
26
+ items: [
27
+ { cita: "Genial", autor: "A", rating: 5 },
28
+ { cita: "Muy bueno", autor: "B" },
29
+ ],
30
+ }
31
+ const html = renderToStaticMarkup(<testimonialsBlock.Component props={props} ctx={mockCtx()} />)
32
+ expect((html.match(/<figure/g) ?? []).length).toBe(2)
33
+ })
34
+
35
+ it("rating opcional: con rating pinta estrellas accesibles, sin rating no", () => {
36
+ const con = renderToStaticMarkup(
37
+ <testimonialsBlock.Component
38
+ props={{ fondo: "claro", titulo: "T", items: [{ cita: "c", autor: "a", rating: 4 }] }}
39
+ ctx={mockCtx()}
40
+ />
41
+ )
42
+ expect(con).toContain('aria-label="4 de 5 estrellas"')
43
+
44
+ const sin = renderToStaticMarkup(
45
+ <testimonialsBlock.Component
46
+ props={{ fondo: "claro", titulo: "T", items: [{ cita: "c", autor: "a" }] }}
47
+ ctx={mockCtx()}
48
+ />
49
+ )
50
+ expect(sin).not.toContain("de 5 estrellas")
51
+ })
52
+
53
+ it("rating fuera de rango no pasa el schema", () => {
54
+ const props = { fondo: "claro" as const, titulo: "T", items: [{ cita: "c", autor: "a", rating: 9 }] }
55
+ expect(testimonialsBlock.schema.safeParse(props).success).toBe(false)
56
+ })
57
+
58
+ it("usa <h2> y <blockquote>, nunca <h1>", () => {
59
+ const html = renderToStaticMarkup(
60
+ <testimonialsBlock.Component props={testimonialsBlock.defaults} ctx={mockCtx()} />
61
+ )
62
+ expect(html).toContain("<h2")
63
+ expect(html).toContain("<blockquote")
64
+ expect(html).not.toMatch(/<h1[ >]/)
65
+ })
66
+
67
+ it("emite motion respetando prefers-reduced-motion", () => {
68
+ const html = renderToStaticMarkup(
69
+ <testimonialsBlock.Component props={testimonialsBlock.defaults} ctx={mockCtx()} />
70
+ )
71
+ expect(html).toContain("prefers-reduced-motion")
72
+ expect(html).toContain("cms-reveal")
73
+ })
74
+ })