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