@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,104 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import type { RenderContext } from "../context"
4
+ import { CLASE_REVEAL, Seccion, TituloSeccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
5
+
6
+ /** Prueba social: tarjetas `{ cita, autor, rating? }`. `rating` es 1–5 estrellas. */
7
+
8
+ export type Testimonial = {
9
+ cita: string
10
+ autor: string
11
+ rating?: number
12
+ }
13
+
14
+ export type TestimonialsProps = {
15
+ fondo: Fondo
16
+ titulo: string
17
+ items: Testimonial[]
18
+ }
19
+
20
+ const testimonialSchema: z.ZodType<Testimonial> = z.object({
21
+ cita: z.string().min(1),
22
+ autor: z.string().min(1),
23
+ rating: z.number().min(1).max(5).optional(),
24
+ })
25
+
26
+ const testimonialsSchema: z.ZodType<TestimonialsProps> = z.object({
27
+ fondo: z.enum(FONDOS),
28
+ titulo: z.string().min(1),
29
+ items: z.array(testimonialSchema),
30
+ })
31
+
32
+ const testimonialsDefaults: TestimonialsProps = {
33
+ fondo: "oscuro",
34
+ titulo: "Lo que dicen nuestros clientes",
35
+ items: [
36
+ { cita: "La calidad me sorprendió, la tela es hermosa y el envío llegó en dos días.", autor: "Camila R.", rating: 5 },
37
+ { cita: "Pedí mi talla usual y quedó perfecta. Volveré a comprar sin dudarlo.", autor: "Andrés M.", rating: 5 },
38
+ { cita: "Atención de otro nivel por WhatsApp, resolvieron un cambio en minutos.", autor: "Valentina G.", rating: 4 },
39
+ ],
40
+ }
41
+
42
+ /** Fila de estrellas accesible (rating 1–5). */
43
+ function Estrellas({ rating, color }: { rating: number; color: string }) {
44
+ const valor = Math.max(1, Math.min(5, Math.round(rating)))
45
+ return (
46
+ <div className="flex gap-0.5" role="img" aria-label={`${valor} de 5 estrellas`} style={{ color }}>
47
+ {Array.from({ length: 5 }).map((_, i) => (
48
+ <span key={i} aria-hidden style={{ opacity: i < valor ? 1 : 0.28 }}>
49
+
50
+ </span>
51
+ ))}
52
+ </div>
53
+ )
54
+ }
55
+
56
+ function Tarjeta({ item, borde, colorSuave, acento }: {
57
+ item: Testimonial
58
+ borde: string
59
+ colorSuave: string
60
+ acento: string
61
+ }) {
62
+ return (
63
+ <figure
64
+ className={`${CLASE_REVEAL} m-0 flex flex-col gap-4 p-6 [transition:transform_.3s_ease,box-shadow_.3s_ease] hover:-translate-y-1 hover:[box-shadow:var(--cms-sombra-md,0_10px_30px_-12px_rgba(0,0,0,.18))]`}
65
+ style={{ border: `var(--cms-grosor-borde) solid ${borde}`, borderRadius: "var(--cms-radio-md,12px)" }}
66
+ >
67
+ {typeof item.rating === "number" ? <Estrellas rating={item.rating} color={acento} /> : null}
68
+ <blockquote className="m-0 text-[15px] leading-relaxed" style={{ fontFamily: "var(--cms-fuente-texto)" }}>
69
+ “{item.cita}”
70
+ </blockquote>
71
+ <figcaption className="text-[12px] font-semibold uppercase tracking-[0.08em]" style={{ color: colorSuave }}>
72
+ {item.autor}
73
+ </figcaption>
74
+ </figure>
75
+ )
76
+ }
77
+
78
+ export const testimonialsBlock: BlockDefinition<TestimonialsProps, RenderContext> = {
79
+ label: "Testimonios",
80
+ schema: testimonialsSchema,
81
+ defaults: testimonialsDefaults,
82
+ Component: ({ props }) => {
83
+ const f = resolverFondo(props.fondo)
84
+ const cols = props.items.length >= 3 ? "lg:grid-cols-3" : "lg:grid-cols-2"
85
+ return (
86
+ <Seccion fondo={props.fondo} ariaLabel={props.titulo}>
87
+ <div className="mx-auto w-full max-w-6xl">
88
+ <TituloSeccion className="text-center">{props.titulo}</TituloSeccion>
89
+ <div className={`mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 ${cols}`}>
90
+ {props.items.map((item, i) => (
91
+ <Tarjeta
92
+ key={`${item.autor}-${i}`}
93
+ item={item}
94
+ borde={f.borde}
95
+ colorSuave={f.colorSuave}
96
+ acento={f.acento}
97
+ />
98
+ ))}
99
+ </div>
100
+ </div>
101
+ </Seccion>
102
+ )
103
+ },
104
+ }
@@ -0,0 +1,159 @@
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 { videoBlock, analizarVideo, type VideoProps } from "./video"
6
+ import { FONDOS } from "./_shared"
7
+ import { mockCtx } from "../test/mock-ctx"
8
+
9
+ function render(props: VideoProps) {
10
+ return renderToStaticMarkup(<videoBlock.Component props={props} ctx={mockCtx()} />)
11
+ }
12
+
13
+ /** `src` de la fuente resuelta (archivo o embed); "" si es vacío. */
14
+ function srcDe(url: string): string {
15
+ const r = analizarVideo(url)
16
+ return r.tipo === "vacio" ? "" : r.src
17
+ }
18
+
19
+ const base = videoBlock.defaults
20
+
21
+ describe("videoBlock", () => {
22
+ it("defaults pasan el schema", () => {
23
+ expect(videoBlock.schema.safeParse(videoBlock.defaults).success).toBe(true)
24
+ })
25
+
26
+ it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
27
+ const props = { ...base, fondo }
28
+ expect(videoBlock.schema.safeParse(props).success).toBe(true)
29
+ const html = render(props)
30
+ expect(html).toContain("<section")
31
+ })
32
+
33
+ describe("analizarVideo", () => {
34
+ it("clasifica archivos directos por extensión (con y sin query)", () => {
35
+ expect(analizarVideo("https://cdn.tienda.com/clip.mp4")).toEqual({
36
+ tipo: "archivo",
37
+ src: "https://cdn.tienda.com/clip.mp4",
38
+ mime: "video/mp4",
39
+ })
40
+ expect(analizarVideo("https://cdn.tienda.com/clip.webm?v=2")).toEqual({
41
+ tipo: "archivo",
42
+ src: "https://cdn.tienda.com/clip.webm?v=2",
43
+ mime: "video/webm",
44
+ })
45
+ })
46
+
47
+ it("normaliza URLs de YouTube a /embed/", () => {
48
+ const esperado = "https://www.youtube.com/embed/dQw4w9WgXcQ"
49
+ expect(srcDe("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(esperado)
50
+ expect(srcDe("https://youtu.be/dQw4w9WgXcQ")).toBe(esperado)
51
+ expect(srcDe("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(esperado)
52
+ expect(srcDe("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(esperado)
53
+ })
54
+
55
+ it("normaliza URLs de Vimeo a player.vimeo.com/video/", () => {
56
+ const esperado = "https://player.vimeo.com/video/123456789"
57
+ expect(srcDe("https://vimeo.com/123456789")).toBe(esperado)
58
+ expect(srcDe("https://player.vimeo.com/video/123456789")).toBe(esperado)
59
+ })
60
+
61
+ it("URL vacía o no reconocida cae a vacio", () => {
62
+ expect(analizarVideo("").tipo).toBe("vacio")
63
+ expect(analizarVideo(" ").tipo).toBe("vacio")
64
+ expect(analizarVideo("https://example.com/pagina").tipo).toBe("vacio")
65
+ })
66
+ })
67
+
68
+ it("archivo MP4: emite <video controls> con <source> del mime correcto y sin <iframe>", () => {
69
+ const html = render({ ...base, videoUrl: "https://cdn.tienda.com/clip.mp4" })
70
+ expect(html).toContain("<video")
71
+ expect(html).toContain("controls")
72
+ expect(html).toContain('<source src="https://cdn.tienda.com/clip.mp4" type="video/mp4"')
73
+ expect(html).not.toContain("<iframe")
74
+ })
75
+
76
+ it("autoplay: agrega autoPlay/muted/loop; sin autoplay no los agrega", () => {
77
+ const con = render({ ...base, videoUrl: "https://cdn.tienda.com/clip.mp4", autoplay: true })
78
+ expect(con).toContain("autoPlay")
79
+ expect(con).toContain('muted=""')
80
+ expect(con).toContain('loop=""')
81
+
82
+ const sin = render({ ...base, videoUrl: "https://cdn.tienda.com/clip.mp4", autoplay: false })
83
+ expect(sin).not.toContain("autoPlay")
84
+ expect(sin).not.toContain('muted=""')
85
+ expect(sin).not.toContain('loop=""')
86
+ })
87
+
88
+ it("poster: se aplica como atributo del <video>", () => {
89
+ const html = render({
90
+ ...base,
91
+ videoUrl: "https://cdn.tienda.com/clip.mp4",
92
+ poster: "https://cdn.tienda.com/poster.jpg",
93
+ })
94
+ expect(html).toContain('poster="https://cdn.tienda.com/poster.jpg"')
95
+ })
96
+
97
+ it("YouTube: emite <iframe> a /embed/ con loading=lazy y sin <video>", () => {
98
+ const html = render({ ...base, videoUrl: "https://youtu.be/dQw4w9WgXcQ" })
99
+ expect(html).toContain("<iframe")
100
+ expect(html).toContain('src="https://www.youtube.com/embed/dQw4w9WgXcQ"')
101
+ expect(html).toContain('loading="lazy"')
102
+ expect(html).not.toContain("<video")
103
+ })
104
+
105
+ it("Vimeo: emite <iframe> al player", () => {
106
+ const html = render({ ...base, videoUrl: "https://vimeo.com/123456789" })
107
+ expect(html).toContain('src="https://player.vimeo.com/video/123456789"')
108
+ })
109
+
110
+ it("vacío sin poster: placeholder con ícono (role=img), sin <video> ni <iframe>", () => {
111
+ const html = render({ ...base, videoUrl: "", poster: "" })
112
+ expect(html).not.toContain("<video")
113
+ expect(html).not.toContain("<iframe")
114
+ expect(html).toContain('role="img"')
115
+ expect(html).toContain("<svg")
116
+ })
117
+
118
+ it("vacío con poster: muestra la portada vía ctx.Imagen (no <iframe>)", () => {
119
+ const html = render({ ...base, videoUrl: "", poster: "https://cdn.tienda.com/poster.jpg" })
120
+ expect(html).not.toContain("<iframe")
121
+ expect(html).toContain('src="https://cdn.tienda.com/poster.jpg"')
122
+ })
123
+
124
+ it("titulo/texto opcionales: se muestran si vienen, se omiten si no", () => {
125
+ const con = render({ ...base, titulo: "Mi título", texto: "Mi texto" })
126
+ expect(con).toContain("Mi título")
127
+ expect(con).toContain("Mi texto")
128
+ expect(con).toContain("<h2")
129
+ expect(con).not.toMatch(/<h1[ >]/)
130
+
131
+ const sin = render({ fondo: "claro", videoUrl: "" })
132
+ expect(sin).not.toContain("<h2")
133
+ })
134
+
135
+ it("enlace opcional: con enlace pinta CTA, sin enlace no", () => {
136
+ const con = render({ ...base, enlace: { texto: "Ver más", href: "/x" } })
137
+ expect(con).toContain("Ver más")
138
+ expect(con).toContain('href="/x"')
139
+
140
+ const sin = render({ fondo: "claro", videoUrl: "" })
141
+ expect(sin).not.toContain("<a")
142
+ })
143
+
144
+ it("emite motion respetando prefers-reduced-motion", () => {
145
+ const html = render(base)
146
+ expect(html).toContain("prefers-reduced-motion")
147
+ expect(html).toContain("cms-reveal")
148
+ })
149
+
150
+ it("es server-safe: sin \"use client\", sin hooks ni Date.now a nivel de módulo", () => {
151
+ const ruta = fileURLToPath(new URL("./video.tsx", import.meta.url))
152
+ const fuente = readFileSync(ruta, "utf-8")
153
+ expect(fuente).not.toMatch(/["']use client["']/)
154
+ expect(fuente).not.toMatch(/\bDate\.now\s*\(/)
155
+ expect(fuente).not.toMatch(/\buse(State|Effect|Ref|Memo|Reducer)\s*\(/)
156
+ // Media/enlace pasan por nativos server-safe o ctx.componentes: sin <a> directo.
157
+ expect(fuente).not.toMatch(/<a[\s>]/)
158
+ })
159
+ })
@@ -0,0 +1,226 @@
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, Seccion, TituloSeccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
6
+
7
+ /**
8
+ * Bloque de video editable. Soporta dos fuentes, detectadas por la URL:
9
+ * - Archivo directo (`.mp4/.webm/.ogg/.ogv/.mov`) → `<video controls>` nativo,
10
+ * con `muted+loop+autoPlay` si `autoplay` está activo (autoplay solo funciona
11
+ * silenciado en los navegadores modernos).
12
+ * - YouTube / Vimeo → `<iframe>` responsivo 16/9 con `loading="lazy"`.
13
+ * Si `videoUrl` viene vacío o no se reconoce, se muestra un placeholder de
14
+ * superficie con el `poster` (si hay) o un ícono de play.
15
+ *
16
+ * Este módulo es SERVER-SAFE: video/iframe son nativos y no hay hooks ni estado.
17
+ * NO lleva la directiva de cliente (rompería el consumo de sus exports en el
18
+ * servidor/RSC). Nunca usa un reloj ni nada no determinista a nivel de módulo.
19
+ */
20
+
21
+ export type VideoProps = {
22
+ fondo: Fondo
23
+ titulo?: string
24
+ texto?: string
25
+ /** URL de un MP4/webm directo o de un embed de YouTube/Vimeo. */
26
+ videoUrl: string
27
+ /** Imagen de portada (URL). Se usa como `poster` del video y en el placeholder. */
28
+ poster?: string
29
+ /** Reproducción automática (silenciada, en bucle). Solo aplica a archivos directos. */
30
+ autoplay?: boolean
31
+ enlace?: { texto: string; href: string }
32
+ }
33
+
34
+ const videoSchema: z.ZodType<VideoProps> = z.object({
35
+ fondo: z.enum(FONDOS),
36
+ titulo: z.string().optional(),
37
+ texto: z.string().optional(),
38
+ videoUrl: z.string(),
39
+ poster: z.string().optional(),
40
+ autoplay: z.boolean().optional(),
41
+ enlace: linkSchema.optional(),
42
+ })
43
+
44
+ const videoDefaults: VideoProps = {
45
+ fondo: "claro",
46
+ titulo: "Nuestra historia en video",
47
+ texto: "Descubrí cómo nace cada prenda, del primer boceto a tu clóset.",
48
+ videoUrl: "",
49
+ poster: "",
50
+ autoplay: false,
51
+ enlace: { texto: "Ver el detrás de escena", href: "/catalogo" },
52
+ }
53
+
54
+ // ── Detección de fuente ────────────────────────────────────────────────────────
55
+
56
+ const EXT_MIME: Record<string, string> = {
57
+ mp4: "video/mp4",
58
+ webm: "video/webm",
59
+ ogv: "video/ogg",
60
+ ogg: "video/ogg",
61
+ mov: "video/quicktime",
62
+ }
63
+
64
+ type VideoAnalisis =
65
+ | { tipo: "archivo"; src: string; mime: string }
66
+ | { tipo: "embed"; src: string }
67
+ | { tipo: "vacio" }
68
+
69
+ /** Recorta querystring/hash para inspeccionar la extensión de un archivo. */
70
+ function rutaSinQuery(url: string): string {
71
+ const i = url.search(/[?#]/)
72
+ return i === -1 ? url : url.slice(0, i)
73
+ }
74
+
75
+ /**
76
+ * Clasifica la URL en archivo directo / embed (YouTube/Vimeo) / vacío. Puro y
77
+ * determinista; toda URL no reconocida cae a `vacio` (muestra el placeholder).
78
+ */
79
+ export function analizarVideo(url: string): VideoAnalisis {
80
+ const u = (url ?? "").trim()
81
+ if (!u) return { tipo: "vacio" }
82
+
83
+ const limpio = rutaSinQuery(u).toLowerCase()
84
+ const punto = limpio.lastIndexOf(".")
85
+ const ext = punto === -1 ? "" : limpio.slice(punto + 1)
86
+ if (ext && EXT_MIME[ext]) return { tipo: "archivo", src: u, mime: EXT_MIME[ext] }
87
+
88
+ const yt = u.match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([\w-]{6,})/i)
89
+ if (yt) return { tipo: "embed", src: `https://www.youtube.com/embed/${yt[1]}` }
90
+
91
+ const vm = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i)
92
+ if (vm) return { tipo: "embed", src: `https://player.vimeo.com/video/${vm[1]}` }
93
+
94
+ return { tipo: "vacio" }
95
+ }
96
+
97
+ // ── Piezas de render ───────────────────────────────────────────────────────────
98
+
99
+ function IconoPlay({ color }: { color: string }) {
100
+ return (
101
+ <svg
102
+ viewBox="0 0 24 24"
103
+ width="44"
104
+ height="44"
105
+ fill="none"
106
+ stroke={color}
107
+ strokeWidth={1.5}
108
+ strokeLinecap="round"
109
+ strokeLinejoin="round"
110
+ aria-hidden="true"
111
+ focusable="false"
112
+ >
113
+ <circle cx="12" cy="12" r="10" />
114
+ <path d="M10 8.5l6 3.5-6 3.5z" fill={color} stroke="none" />
115
+ </svg>
116
+ )
117
+ }
118
+
119
+ function Placeholder({
120
+ poster,
121
+ ctx,
122
+ ariaLabel,
123
+ colorSuave,
124
+ borde,
125
+ }: {
126
+ poster?: string
127
+ ctx: RenderContext
128
+ ariaLabel: string
129
+ colorSuave: string
130
+ borde: string
131
+ }) {
132
+ const { Imagen } = ctx.componentes
133
+ if (poster) {
134
+ return <Imagen src={poster} alt={ariaLabel} className="absolute inset-0 h-full w-full object-cover" />
135
+ }
136
+ return (
137
+ <div
138
+ className="absolute inset-0 flex items-center justify-center"
139
+ role="img"
140
+ aria-label={ariaLabel}
141
+ style={{ background: "var(--cms-superficie,#fff)", border: `var(--cms-grosor-borde) solid ${borde}` }}
142
+ >
143
+ <IconoPlay color={colorSuave} />
144
+ </div>
145
+ )
146
+ }
147
+
148
+ export const videoBlock: BlockDefinition<VideoProps, RenderContext> = {
149
+ label: "Video",
150
+ schema: videoSchema,
151
+ defaults: videoDefaults,
152
+ Component: ({ props, ctx }) => {
153
+ const f = resolverFondo(props.fondo)
154
+ const { Enlace } = ctx.componentes
155
+ const analisis = analizarVideo(props.videoUrl)
156
+ const etiquetaMedia = props.titulo || "Video"
157
+ const poster = props.poster && props.poster.trim() ? props.poster.trim() : undefined
158
+ const auto = props.autoplay === true
159
+
160
+ return (
161
+ <Seccion fondo={props.fondo} ariaLabel={etiquetaMedia}>
162
+ <div className="mx-auto w-full max-w-5xl">
163
+ {props.titulo || props.texto ? (
164
+ <div className="mb-6 flex flex-col gap-3 text-center">
165
+ {props.titulo ? <TituloSeccion>{props.titulo}</TituloSeccion> : null}
166
+ {props.texto ? (
167
+ <p className="m-0 mx-auto max-w-2xl text-[15px] leading-relaxed" style={{ color: f.colorSuave }}>
168
+ {props.texto}
169
+ </p>
170
+ ) : null}
171
+ </div>
172
+ ) : null}
173
+
174
+ <div
175
+ className={`${CLASE_REVEAL} relative aspect-video w-full overflow-hidden`}
176
+ style={{ borderRadius: "var(--cms-radio)" }}
177
+ >
178
+ {analisis.tipo === "archivo" ? (
179
+ <video
180
+ className="absolute inset-0 h-full w-full bg-black object-cover"
181
+ controls
182
+ playsInline
183
+ poster={poster}
184
+ autoPlay={auto}
185
+ muted={auto}
186
+ loop={auto}
187
+ preload={auto ? "auto" : "metadata"}
188
+ >
189
+ <source src={analisis.src} type={analisis.mime} />
190
+ </video>
191
+ ) : analisis.tipo === "embed" ? (
192
+ <iframe
193
+ className="absolute inset-0 h-full w-full"
194
+ src={analisis.src}
195
+ title={etiquetaMedia}
196
+ loading="lazy"
197
+ referrerPolicy="strict-origin-when-cross-origin"
198
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
199
+ allowFullScreen
200
+ />
201
+ ) : (
202
+ <Placeholder
203
+ poster={poster}
204
+ ctx={ctx}
205
+ ariaLabel={etiquetaMedia}
206
+ colorSuave={f.colorSuave}
207
+ borde={f.borde}
208
+ />
209
+ )}
210
+ </div>
211
+
212
+ {props.enlace && props.enlace.texto ? (
213
+ <div className="mt-6 text-center">
214
+ <Enlace
215
+ href={props.enlace.href}
216
+ 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)]"
217
+ >
218
+ {props.enlace.texto}
219
+ </Enlace>
220
+ </div>
221
+ ) : null}
222
+ </div>
223
+ </Seccion>
224
+ )
225
+ },
226
+ }