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