@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,159 @@
1
+ import { z } from "zod"
2
+ import type { ReactNode } from "react"
3
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
4
+ import type { RenderContext } from "../context"
5
+ import { CLASE_REVEAL, Seccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
6
+
7
+ /**
8
+ * Franja de confianza: 3–4 items `{ icono, titulo, texto }`. Los iconos son un
9
+ * set pequeño inline (stroke `currentColor`), elegibles por enum desde el CMS.
10
+ */
11
+
12
+ export const ICONOS_FEATURE = ["envio", "devoluciones", "pago-seguro", "soporte", "calidad", "cuotas"] as const
13
+ export type IconoFeature = (typeof ICONOS_FEATURE)[number]
14
+
15
+ export type FeatureItem = {
16
+ icono: IconoFeature
17
+ titulo: string
18
+ texto: string
19
+ }
20
+
21
+ export type FeaturesProps = {
22
+ fondo: Fondo
23
+ items: FeatureItem[]
24
+ }
25
+
26
+ const featureItemSchema: z.ZodType<FeatureItem> = z.object({
27
+ icono: z.enum(ICONOS_FEATURE),
28
+ titulo: z.string().min(1),
29
+ texto: z.string(),
30
+ })
31
+
32
+ const featuresSchema: z.ZodType<FeaturesProps> = z.object({
33
+ fondo: z.enum(FONDOS),
34
+ items: z.array(featureItemSchema),
35
+ })
36
+
37
+ const featuresDefaults: FeaturesProps = {
38
+ fondo: "superficie",
39
+ items: [
40
+ { icono: "envio", titulo: "Envío a todo el país", texto: "Despachamos en 24–48h a toda Colombia." },
41
+ { icono: "devoluciones", titulo: "Cambios y devoluciones", texto: "30 días para cambiar lo que no te quede." },
42
+ { icono: "pago-seguro", titulo: "Pago 100% seguro", texto: "PSE, tarjeta o efectivo con cifrado." },
43
+ { icono: "soporte", titulo: "Soporte cercano", texto: "Te acompañamos por chat y WhatsApp." },
44
+ ],
45
+ }
46
+
47
+ /** Envoltura SVG común: 24×24, stroke `currentColor`, decorativa (aria-hidden). */
48
+ function IconoBase({ children }: { children: ReactNode }) {
49
+ return (
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ viewBox="0 0 24 24"
53
+ width="24"
54
+ height="24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth={1.6}
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ aria-hidden="true"
61
+ focusable="false"
62
+ >
63
+ {children}
64
+ </svg>
65
+ )
66
+ }
67
+
68
+ const ICONO_SVG: Record<IconoFeature, ReactNode> = {
69
+ envio: (
70
+ <IconoBase>
71
+ <path d="M3 6.5h10v9H3z" />
72
+ <path d="M13 9h4l3 3v3.5h-7z" />
73
+ <circle cx="7" cy="17.5" r="1.6" />
74
+ <circle cx="17" cy="17.5" r="1.6" />
75
+ </IconoBase>
76
+ ),
77
+ devoluciones: (
78
+ <IconoBase>
79
+ <path d="M4 9a8 8 0 1 1-1.2 5" />
80
+ <path d="M3 4v5h5" />
81
+ </IconoBase>
82
+ ),
83
+ "pago-seguro": (
84
+ <IconoBase>
85
+ <path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" />
86
+ <path d="M9.5 12l1.8 1.8L15 10.2" />
87
+ </IconoBase>
88
+ ),
89
+ soporte: (
90
+ <IconoBase>
91
+ <path d="M4 12a8 8 0 0 1 16 0" />
92
+ <rect x="3" y="12" width="3.5" height="5.5" rx="1.2" />
93
+ <rect x="17.5" y="12" width="3.5" height="5.5" rx="1.2" />
94
+ <path d="M20 17.5c0 2-1.8 3-4 3" />
95
+ </IconoBase>
96
+ ),
97
+ calidad: (
98
+ <IconoBase>
99
+ <path d="M12 3.5l2.5 5 5.5.8-4 3.9 1 5.5L12 16l-5 2.6 1-5.5-4-3.9 5.5-.8z" />
100
+ </IconoBase>
101
+ ),
102
+ cuotas: (
103
+ <IconoBase>
104
+ <rect x="3" y="6" width="18" height="12" rx="2" />
105
+ <path d="M3 10h18" />
106
+ <path d="M7 14.5h4" />
107
+ </IconoBase>
108
+ ),
109
+ }
110
+
111
+ function Feature({ item, borde, colorSuave, acento }: {
112
+ item: FeatureItem
113
+ borde: string
114
+ colorSuave: string
115
+ acento: string
116
+ }) {
117
+ return (
118
+ <div
119
+ className={`${CLASE_REVEAL} flex flex-col items-start gap-2.5 p-5 [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))]`}
120
+ style={{ border: `var(--cms-grosor-borde) solid ${borde}`, borderRadius: "var(--cms-radio-md,12px)" }}
121
+ >
122
+ <span style={{ color: acento }}>{ICONO_SVG[item.icono]}</span>
123
+ <h3
124
+ className="m-0 text-[15px] font-semibold"
125
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
126
+ >
127
+ {item.titulo}
128
+ </h3>
129
+ <p className="m-0 text-[13px] leading-relaxed" style={{ color: colorSuave }}>
130
+ {item.texto}
131
+ </p>
132
+ </div>
133
+ )
134
+ }
135
+
136
+ export const featuresBlock: BlockDefinition<FeaturesProps, RenderContext> = {
137
+ label: "Franja de confianza",
138
+ schema: featuresSchema,
139
+ defaults: featuresDefaults,
140
+ Component: ({ props }) => {
141
+ const f = resolverFondo(props.fondo)
142
+ const cols = props.items.length >= 4 ? "lg:grid-cols-4" : "lg:grid-cols-3"
143
+ return (
144
+ <Seccion fondo={props.fondo} ariaLabel="Ventajas de comprar con nosotros">
145
+ <div className={`mx-auto grid w-full max-w-6xl grid-cols-1 gap-4 sm:grid-cols-2 ${cols}`}>
146
+ {props.items.map((item, i) => (
147
+ <Feature
148
+ key={`${item.icono}-${i}`}
149
+ item={item}
150
+ borde={f.borde}
151
+ colorSuave={f.colorSuave}
152
+ acento={f.acento}
153
+ />
154
+ ))}
155
+ </div>
156
+ </Seccion>
157
+ )
158
+ },
159
+ }
@@ -0,0 +1,75 @@
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 { heroBlock } from "./hero"
6
+ import { FONDOS, resolverFondo } from "./_shared"
7
+ import { mockCtx } from "../test/mock-ctx"
8
+
9
+ const layouts = ["full-bleed", "split", "centered", "grid"] as const
10
+
11
+ function contarH1(html: string): number {
12
+ return (html.match(/<h1[ >]/g) ?? []).length
13
+ }
14
+
15
+ describe("heroBlock", () => {
16
+ it.each(layouts)("renderiza sin crash el layout %s con titulo, CTA primario y un solo h1", (layout) => {
17
+ const props = { ...heroBlock.defaults, layout }
18
+ expect(heroBlock.schema.safeParse(props).success).toBe(true)
19
+
20
+ const html = renderToStaticMarkup(<heroBlock.Component props={props} ctx={mockCtx()} />)
21
+
22
+ expect(html).toContain(heroBlock.defaults.titulo)
23
+ expect(html).toContain(heroBlock.defaults.enlacePrimario.texto)
24
+ expect(contarH1(html)).toBe(1)
25
+ })
26
+
27
+ it("con imagenUrl vacío usa el fallback del mock Imagen en vez de <img src=\"\">", () => {
28
+ const props = { ...heroBlock.defaults, layout: "full-bleed" as const, imagenUrl: "" }
29
+ const html = renderToStaticMarkup(<heroBlock.Component props={props} ctx={mockCtx()} />)
30
+
31
+ expect(html).not.toContain('src=""')
32
+ expect(html).toContain('role="img"')
33
+ })
34
+
35
+ it("defaults pasan el schema", () => {
36
+ expect(heroBlock.schema.safeParse(heroBlock.defaults).success).toBe(true)
37
+ })
38
+
39
+ it("el fondo por defecto es 'claro' (preserva el look actual)", () => {
40
+ expect(heroBlock.defaults.fondo).toBe("claro")
41
+ })
42
+
43
+ it.each(FONDOS)("cualquier fondo (%s) pasa el schema", (fondo) => {
44
+ expect(heroBlock.schema.safeParse({ ...heroBlock.defaults, fondo }).success).toBe(true)
45
+ })
46
+
47
+ it.each(["split", "centered", "grid"] as const)(
48
+ "el layout %s aplica el fondo elegido al contenedor",
49
+ (layout) => {
50
+ const oscuro = resolverFondo("oscuro")
51
+ const html = renderToStaticMarkup(
52
+ <heroBlock.Component props={{ ...heroBlock.defaults, layout, fondo: "oscuro" }} ctx={mockCtx()} />
53
+ )
54
+ expect(html).toContain(oscuro.background)
55
+ }
56
+ )
57
+
58
+ it("full-bleed lo manda la imagen: el fondo no altera su markup", () => {
59
+ const claro = renderToStaticMarkup(
60
+ <heroBlock.Component props={{ ...heroBlock.defaults, layout: "full-bleed", fondo: "claro" }} ctx={mockCtx()} />
61
+ )
62
+ const oscuro = renderToStaticMarkup(
63
+ <heroBlock.Component props={{ ...heroBlock.defaults, layout: "full-bleed", fondo: "oscuro" }} ctx={mockCtx()} />
64
+ )
65
+ expect(oscuro).toBe(claro)
66
+ })
67
+
68
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
69
+ const rutaFuente = fileURLToPath(new URL("./hero.tsx", import.meta.url))
70
+ const fuente = readFileSync(rutaFuente, "utf-8")
71
+
72
+ expect(fuente).not.toMatch(/<img[\s>]/)
73
+ expect(fuente).not.toMatch(/<a[\s>]/)
74
+ })
75
+ })
@@ -0,0 +1,290 @@
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 { resolverFondo, type Fondo, FONDOS } from "./_shared"
6
+
7
+ export type HeroProps = {
8
+ layout: "full-bleed" | "split" | "centered" | "grid"
9
+ alineacion: "izquierda" | "centro"
10
+ /**
11
+ * Fondo del contenedor en los layouts SIN imagen de fondo (`split`,
12
+ * `centered`, `grid`). El `full-bleed` es dominado por la imagen y NO se ve
13
+ * afectado. Default `claro`: resuelve a los mismos tokens de tema que ya se
14
+ * usaban, así el render actual no cambia.
15
+ */
16
+ fondo: Fondo
17
+ titulo: string
18
+ subtitulo: string
19
+ etiqueta: string
20
+ imagenUrl: string
21
+ enlacePrimario: { texto: string; href: string }
22
+ enlaceSecundario?: { texto: string; href: string }
23
+ }
24
+
25
+ const heroSchema: z.ZodType<HeroProps> = z.object({
26
+ layout: z.enum(["full-bleed", "split", "centered", "grid"]),
27
+ alineacion: z.enum(["izquierda", "centro"]),
28
+ fondo: z.enum(FONDOS),
29
+ titulo: z.string().min(1),
30
+ subtitulo: z.string(),
31
+ etiqueta: z.string(),
32
+ imagenUrl: z.string(),
33
+ enlacePrimario: linkSchema,
34
+ enlaceSecundario: linkSchema.optional(),
35
+ })
36
+
37
+ const heroDefaults: HeroProps = {
38
+ layout: "full-bleed",
39
+ alineacion: "izquierda",
40
+ fondo: "claro",
41
+ titulo: "Nueva temporada",
42
+ subtitulo: "Piezas que viven contigo, todos los días.",
43
+ etiqueta: "Otoño 2026",
44
+ imagenUrl: "",
45
+ enlacePrimario: { texto: "Ver colección", href: "/catalogo" },
46
+ enlaceSecundario: { texto: "Ver lookbook", href: "/catalogo" },
47
+ }
48
+
49
+ function Etiqueta({ children, className }: { children: string; className?: string }) {
50
+ if (!children) return null
51
+ return (
52
+ <div
53
+ className={`font-mono text-[11px] font-semibold tracking-[0.16em] ${className ?? ""}`}
54
+ style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-acento)" }}
55
+ >
56
+ {children}
57
+ </div>
58
+ )
59
+ }
60
+
61
+ /** Único `<h1>` del bloque hero: el título de la pieza. */
62
+ function Titulo({ children, className }: { children: string; className?: string }) {
63
+ return (
64
+ <h1
65
+ className={`m-0 leading-[1.05] ${className ?? ""}`}
66
+ style={{
67
+ fontFamily: "var(--cms-fuente-titulo)",
68
+ fontWeight: "var(--cms-titulo-peso)",
69
+ textTransform: "var(--cms-titulo-transform)",
70
+ letterSpacing: "var(--cms-titulo-espaciado)",
71
+ }}
72
+ >
73
+ {children}
74
+ </h1>
75
+ )
76
+ }
77
+
78
+ /** Enlace no acepta `style`: la variable de tema va en clases arbitrarias de Tailwind. */
79
+ function BotonPrimario({
80
+ texto,
81
+ href,
82
+ ctx,
83
+ className,
84
+ }: {
85
+ texto: string
86
+ href: string
87
+ ctx: RenderContext
88
+ className?: string
89
+ }) {
90
+ const { Enlace } = ctx.componentes
91
+ return (
92
+ <Enlace
93
+ href={href}
94
+ className={`inline-block px-5 py-2.5 text-center text-xs font-semibold uppercase tracking-[0.08em] [background:var(--cms-primario)] [color:var(--cms-primario-texto)] [border-radius:var(--cms-radio)] [font-family:var(--cms-fuente-texto)] md:px-6 md:py-3 ${className ?? ""}`}
95
+ >
96
+ {texto}
97
+ </Enlace>
98
+ )
99
+ }
100
+
101
+ function BotonSecundario({
102
+ texto,
103
+ href,
104
+ ctx,
105
+ className,
106
+ }: {
107
+ texto: string
108
+ href: string
109
+ ctx: RenderContext
110
+ className?: string
111
+ }) {
112
+ const { Enlace } = ctx.componentes
113
+ return (
114
+ <Enlace
115
+ href={href}
116
+ className={`inline-block px-5 py-2.5 text-center text-xs font-semibold uppercase tracking-[0.08em] [border:1.5px_solid_currentColor] [border-radius:var(--cms-radio)] [font-family:var(--cms-fuente-texto)] md:px-6 md:py-3 ${className ?? ""}`}
117
+ >
118
+ {texto}
119
+ </Enlace>
120
+ )
121
+ }
122
+
123
+ function FullBleed({ props, ctx }: { props: HeroProps; ctx: RenderContext }) {
124
+ const { Imagen } = ctx.componentes
125
+ const alineado = props.alineacion === "centro" ? "text-center items-center" : "text-left items-start"
126
+ return (
127
+ <div
128
+ className="relative aspect-[4/5] w-full overflow-hidden md:aspect-[16/9] lg:h-[440px] lg:aspect-auto"
129
+ style={{ borderRadius: "var(--cms-radio)" }}
130
+ >
131
+ <Imagen
132
+ src={props.imagenUrl}
133
+ alt={props.titulo || props.etiqueta}
134
+ prioridad
135
+ className="absolute inset-0 h-full w-full object-cover"
136
+ />
137
+ <div
138
+ className={`absolute inset-x-0 bottom-0 flex flex-col gap-3 px-5 pb-6 pt-16 text-white md:gap-5 md:px-8 md:pb-10 md:pt-24 ${alineado}`}
139
+ style={{ background: "linear-gradient(transparent, rgba(0,0,0,.72))" }}
140
+ >
141
+ <Etiqueta className="text-white/80">{props.etiqueta}</Etiqueta>
142
+ <Titulo className="max-w-2xl text-3xl md:text-4xl lg:text-5xl">{props.titulo}</Titulo>
143
+ <div className="flex flex-wrap gap-3">
144
+ <BotonPrimario texto={props.enlacePrimario.texto} href={props.enlacePrimario.href} ctx={ctx} />
145
+ {props.enlaceSecundario ? (
146
+ <BotonSecundario texto={props.enlaceSecundario.texto} href={props.enlaceSecundario.href} ctx={ctx} />
147
+ ) : null}
148
+ </div>
149
+ </div>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ function Split({ props, ctx }: { props: HeroProps; ctx: RenderContext }) {
155
+ const { Imagen } = ctx.componentes
156
+ const f = resolverFondo(props.fondo)
157
+ return (
158
+ <div
159
+ className="grid w-full grid-cols-1 overflow-hidden md:grid-cols-[1fr_1.2fr]"
160
+ style={{
161
+ border: `var(--cms-grosor-borde) solid ${f.borde}`,
162
+ borderRadius: "var(--cms-radio)",
163
+ background: f.background,
164
+ color: f.color,
165
+ }}
166
+ >
167
+ <div className="order-2 flex flex-col justify-center gap-3 px-6 py-8 md:order-1 md:gap-4 md:px-12 md:py-14">
168
+ <Etiqueta>{props.etiqueta}</Etiqueta>
169
+ <Titulo className="text-3xl md:text-4xl">{props.titulo}</Titulo>
170
+ <p className="m-0 max-w-sm text-sm leading-relaxed" style={{ color: f.colorSuave }}>
171
+ {props.subtitulo}
172
+ </p>
173
+ <div className="flex flex-wrap gap-3">
174
+ <BotonPrimario texto={props.enlacePrimario.texto} href={props.enlacePrimario.href} ctx={ctx} />
175
+ {props.enlaceSecundario ? (
176
+ <BotonSecundario texto={props.enlaceSecundario.texto} href={props.enlaceSecundario.href} ctx={ctx} />
177
+ ) : null}
178
+ </div>
179
+ </div>
180
+ <Imagen
181
+ src={props.imagenUrl}
182
+ alt={props.titulo || props.etiqueta}
183
+ prioridad
184
+ className="order-1 aspect-[4/5] w-full object-cover md:order-2 md:aspect-auto md:min-h-[400px]"
185
+ />
186
+ </div>
187
+ )
188
+ }
189
+
190
+ function Centered({ props, ctx }: { props: HeroProps; ctx: RenderContext }) {
191
+ const { Imagen } = ctx.componentes
192
+ const f = resolverFondo(props.fondo)
193
+ return (
194
+ <div
195
+ className="overflow-hidden px-5 pt-10 text-center md:px-12 md:pt-16"
196
+ style={{
197
+ background: f.background,
198
+ color: f.color,
199
+ border: `var(--cms-grosor-borde) solid ${f.borde}`,
200
+ borderRadius: "var(--cms-radio)",
201
+ }}
202
+ >
203
+ <Etiqueta className="justify-center">{props.etiqueta}</Etiqueta>
204
+ <Titulo className="mx-auto mt-4 max-w-3xl text-4xl md:text-5xl lg:text-6xl">{props.titulo}</Titulo>
205
+ <p className="mx-auto mt-4 max-w-xl text-sm leading-relaxed" style={{ color: f.colorSuave }}>
206
+ {props.subtitulo}
207
+ </p>
208
+ <div className="mt-6 flex flex-wrap justify-center gap-3">
209
+ <BotonPrimario texto={props.enlacePrimario.texto} href={props.enlacePrimario.href} ctx={ctx} />
210
+ {props.enlaceSecundario ? (
211
+ <BotonSecundario texto={props.enlaceSecundario.texto} href={props.enlaceSecundario.href} ctx={ctx} />
212
+ ) : null}
213
+ </div>
214
+ <Imagen
215
+ src={props.imagenUrl}
216
+ alt={props.titulo || props.etiqueta}
217
+ prioridad
218
+ className="mx-auto mt-8 aspect-[4/5] w-full max-w-4xl object-cover md:mt-10 md:aspect-[21/9]"
219
+ />
220
+ </div>
221
+ )
222
+ }
223
+
224
+ function Grid({ props, ctx }: { props: HeroProps; ctx: RenderContext }) {
225
+ const { Imagen } = ctx.componentes
226
+ const f = resolverFondo(props.fondo)
227
+ return (
228
+ <div
229
+ className="grid w-full grid-cols-1 overflow-hidden md:grid-cols-[1.5fr_1fr] md:grid-rows-[220px_220px]"
230
+ style={{
231
+ background: f.background,
232
+ color: f.color,
233
+ border: `var(--cms-grosor-borde) solid ${f.borde}`,
234
+ borderRadius: "var(--cms-radio)",
235
+ }}
236
+ >
237
+ <div
238
+ className="relative aspect-[4/5] md:aspect-auto"
239
+ style={{ gridRow: "1 / 3", borderRight: `var(--cms-grosor-borde) solid ${f.borde}` }}
240
+ >
241
+ <Imagen
242
+ src={props.imagenUrl}
243
+ alt={props.titulo || props.etiqueta}
244
+ prioridad
245
+ className="absolute inset-0 h-full w-full object-cover"
246
+ />
247
+ <div className="absolute inset-x-4 bottom-4 flex flex-col gap-3 md:inset-x-6 md:bottom-6">
248
+ <Titulo className="text-3xl md:text-4xl">{props.titulo}</Titulo>
249
+ <div>
250
+ <BotonPrimario texto={props.enlacePrimario.texto} href={props.enlacePrimario.href} ctx={ctx} />
251
+ </div>
252
+ </div>
253
+ </div>
254
+ <div
255
+ className="flex flex-col justify-between gap-3 p-4 md:p-5"
256
+ style={{
257
+ borderBottom: `var(--cms-grosor-borde) solid ${f.borde}`,
258
+ background: "var(--cms-superficie)",
259
+ }}
260
+ >
261
+ <Etiqueta>{props.etiqueta}</Etiqueta>
262
+ <div className="text-base font-semibold md:text-lg">{props.subtitulo}</div>
263
+ </div>
264
+ <div className="flex flex-col justify-between p-4 md:p-5" style={{ background: "var(--cms-superficie)" }}>
265
+ {props.enlaceSecundario ? (
266
+ <BotonSecundario texto={props.enlaceSecundario.texto} href={props.enlaceSecundario.href} ctx={ctx} />
267
+ ) : null}
268
+ </div>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ export const heroBlock: BlockDefinition<HeroProps, RenderContext> = {
274
+ label: "Hero",
275
+ schema: heroSchema,
276
+ defaults: heroDefaults,
277
+ Component: ({ props, ctx }) => {
278
+ switch (props.layout) {
279
+ case "split":
280
+ return <Split props={props} ctx={ctx} />
281
+ case "centered":
282
+ return <Centered props={props} ctx={ctx} />
283
+ case "grid":
284
+ return <Grid props={props} ctx={ctx} />
285
+ case "full-bleed":
286
+ default:
287
+ return <FullBleed props={props} ctx={ctx} />
288
+ }
289
+ },
290
+ }
@@ -0,0 +1,35 @@
1
+ import { defineBlocks } from "@arroyavecommerce/cms-core"
2
+ import type { RenderContext } from "../context"
3
+ import { heroBlock } from "./hero"
4
+ import { categoryTilesBlock } from "./category-tiles"
5
+ import { productGridBlock } from "./product-grid"
6
+ import { editorialBannerBlock } from "./editorial-banner"
7
+ import { newsletterBlock } from "./newsletter"
8
+ import { featuresBlock } from "./features"
9
+ import { marqueeBlock } from "./marquee"
10
+ import { featuredCollectionBlock } from "./featured-collection"
11
+ import { testimonialsBlock } from "./testimonials"
12
+ import { lookbookBlock } from "./lookbook"
13
+ import { faqBlock } from "./faq"
14
+ import { richTextBlock } from "./rich-text"
15
+ import { spacerBlock } from "./spacer"
16
+ import { promoBlock } from "./promo"
17
+ import { videoBlock } from "./video"
18
+
19
+ export const homeBlocks = defineBlocks<RenderContext>()({
20
+ hero: heroBlock,
21
+ categoryTiles: categoryTilesBlock,
22
+ productGrid: productGridBlock,
23
+ editorialBanner: editorialBannerBlock,
24
+ newsletter: newsletterBlock,
25
+ features: featuresBlock,
26
+ marquee: marqueeBlock,
27
+ featuredCollection: featuredCollectionBlock,
28
+ testimonials: testimonialsBlock,
29
+ lookbook: lookbookBlock,
30
+ faq: faqBlock,
31
+ richText: richTextBlock,
32
+ spacer: spacerBlock,
33
+ promo: promoBlock,
34
+ video: videoBlock,
35
+ })
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { lookbookBlock } from "./lookbook"
4
+ import { FONDOS } from "./_shared"
5
+ import { mockCtx } from "../test/mock-ctx"
6
+
7
+ describe("lookbookBlock", () => {
8
+ it("defaults pasan el schema", () => {
9
+ expect(lookbookBlock.schema.safeParse(lookbookBlock.defaults).success).toBe(true)
10
+ })
11
+
12
+ it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
13
+ const props = { ...lookbookBlock.defaults, fondo }
14
+ expect(lookbookBlock.schema.safeParse(props).success).toBe(true)
15
+ const html = renderToStaticMarkup(<lookbookBlock.Component props={props} ctx={mockCtx()} />)
16
+ expect(html).toContain(lookbookBlock.defaults.items[0].titulo!)
17
+ })
18
+
19
+ it("renderiza un mosaico por item (editable)", () => {
20
+ const props = {
21
+ fondo: "claro" as const,
22
+ titulo: "Galería",
23
+ items: [
24
+ { imagenUrl: "", titulo: "A" },
25
+ { imagenUrl: "", titulo: "B" },
26
+ ],
27
+ }
28
+ const html = renderToStaticMarkup(<lookbookBlock.Component props={props} ctx={mockCtx()} />)
29
+ expect((html.match(/<figure/g) ?? []).length).toBe(2)
30
+ })
31
+
32
+ it("usa la Imagen del ctx: placeholder accesible cuando la URL viene vacía", () => {
33
+ const props = { fondo: "claro" as const, items: [{ imagenUrl: "", titulo: "Sin foto" }] }
34
+ const html = renderToStaticMarkup(<lookbookBlock.Component props={props} ctx={mockCtx()} />)
35
+ // El mock de Imagen pinta un <div role="img"> cuando no hay src.
36
+ expect(html).toContain('role="img"')
37
+ expect(html).not.toContain("<img")
38
+ })
39
+
40
+ it("con imagenUrl renderiza <img> con esa fuente", () => {
41
+ const props = { fondo: "claro" as const, items: [{ imagenUrl: "https://x/y.jpg", titulo: "Foto" }] }
42
+ const html = renderToStaticMarkup(<lookbookBlock.Component props={props} ctx={mockCtx()} />)
43
+ expect(html).toContain('src="https://x/y.jpg"')
44
+ })
45
+
46
+ it("envuelve en enlace sólo los items con href", () => {
47
+ const props = {
48
+ fondo: "claro" as const,
49
+ items: [
50
+ { imagenUrl: "", titulo: "Con link", href: "/coleccion" },
51
+ { imagenUrl: "", titulo: "Sin link" },
52
+ ],
53
+ }
54
+ const html = renderToStaticMarkup(<lookbookBlock.Component props={props} ctx={mockCtx()} />)
55
+ expect((html.match(/<a /g) ?? []).length).toBe(1)
56
+ expect(html).toContain('href="/coleccion"')
57
+ })
58
+
59
+ it("titulo opcional: sin titulo no pinta <h2>, con titulo sí", () => {
60
+ const sin = renderToStaticMarkup(
61
+ <lookbookBlock.Component props={{ fondo: "claro", items: [{ imagenUrl: "", titulo: "A" }] }} ctx={mockCtx()} />
62
+ )
63
+ expect(sin).not.toContain("<h2")
64
+
65
+ const con = renderToStaticMarkup(
66
+ <lookbookBlock.Component props={{ fondo: "claro", titulo: "Editorial", items: [] }} ctx={mockCtx()} />
67
+ )
68
+ expect(con).toContain("<h2")
69
+ expect(con).toContain("Editorial")
70
+ })
71
+
72
+ it("no usa <h1> y emite motion respetando prefers-reduced-motion", () => {
73
+ const html = renderToStaticMarkup(
74
+ <lookbookBlock.Component props={lookbookBlock.defaults} ctx={mockCtx()} />
75
+ )
76
+ expect(html).not.toMatch(/<h1[ >]/)
77
+ expect(html).toContain("prefers-reduced-motion")
78
+ expect(html).toContain("cms-reveal")
79
+ })
80
+ })