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