@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,205 @@
|
|
|
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 EditorialBannerProps = {
|
|
8
|
+
posicionImagen: "derecha" | "izquierda" | "fondo"
|
|
9
|
+
/**
|
|
10
|
+
* Fondo del panel en las variantes con imagen al lado (`derecha`,
|
|
11
|
+
* `izquierda`). La variante `fondo` (imagen a sangre) NO se ve afectada.
|
|
12
|
+
* Default `superficie`: resuelve a los mismos tokens que ya se usaban, así el
|
|
13
|
+
* render actual no cambia.
|
|
14
|
+
*/
|
|
15
|
+
fondo: Fondo
|
|
16
|
+
titulo: string
|
|
17
|
+
texto: string
|
|
18
|
+
imagenUrl: string
|
|
19
|
+
enlace: { texto: string; href: string }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const editorialBannerSchema: z.ZodType<EditorialBannerProps> = z.object({
|
|
23
|
+
posicionImagen: z.enum(["derecha", "izquierda", "fondo"]),
|
|
24
|
+
fondo: z.enum(FONDOS),
|
|
25
|
+
titulo: z.string().min(1),
|
|
26
|
+
texto: z.string(),
|
|
27
|
+
imagenUrl: z.string(),
|
|
28
|
+
enlace: linkSchema,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const editorialBannerDefaults: EditorialBannerProps = {
|
|
32
|
+
posicionImagen: "derecha",
|
|
33
|
+
fondo: "superficie",
|
|
34
|
+
titulo: "Cómo armar un uniforme urbano con 5 piezas",
|
|
35
|
+
texto: "Guía de estilo: combinaciones para la semana completa sin repetir look.",
|
|
36
|
+
imagenUrl: "",
|
|
37
|
+
enlace: { texto: "Descubrir", href: "/collections/editorial" },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Etiqueta({ children }: { children: string }) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className="text-[11px] font-semibold tracking-[0.16em]"
|
|
44
|
+
style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-acento)" }}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Único heading de sección del bloque: `<h2>` (no `<h1>`, ese es del hero). */
|
|
52
|
+
function Titulo({ children, className }: { children: string; className?: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<h2
|
|
55
|
+
className={`m-0 text-2xl leading-[1.15] md:text-[34px] ${className ?? ""}`}
|
|
56
|
+
style={{
|
|
57
|
+
fontFamily: "var(--cms-fuente-titulo)",
|
|
58
|
+
fontWeight: "var(--cms-titulo-peso)",
|
|
59
|
+
textTransform: "var(--cms-titulo-transform)",
|
|
60
|
+
letterSpacing: "var(--cms-titulo-espaciado)",
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</h2>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Enlace no acepta `style`: la variable de tema va en clases arbitrarias de Tailwind. */
|
|
69
|
+
function BotonSecundario({
|
|
70
|
+
texto,
|
|
71
|
+
href,
|
|
72
|
+
ctx,
|
|
73
|
+
className,
|
|
74
|
+
}: {
|
|
75
|
+
texto: string
|
|
76
|
+
href: string
|
|
77
|
+
ctx: RenderContext
|
|
78
|
+
className?: string
|
|
79
|
+
}) {
|
|
80
|
+
const { Enlace } = ctx.componentes
|
|
81
|
+
return (
|
|
82
|
+
<Enlace
|
|
83
|
+
href={href}
|
|
84
|
+
className={`inline-block 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)] ${className ?? ""}`}
|
|
85
|
+
>
|
|
86
|
+
{texto}
|
|
87
|
+
</Enlace>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Contenido({
|
|
92
|
+
props,
|
|
93
|
+
ctx,
|
|
94
|
+
className,
|
|
95
|
+
}: {
|
|
96
|
+
props: EditorialBannerProps
|
|
97
|
+
ctx: RenderContext
|
|
98
|
+
className?: string
|
|
99
|
+
}) {
|
|
100
|
+
const f = resolverFondo(props.fondo)
|
|
101
|
+
return (
|
|
102
|
+
<div className={`flex flex-col justify-center gap-3 px-6 py-8 md:gap-3.5 md:px-12 md:py-[52px] ${className ?? ""}`}>
|
|
103
|
+
<Etiqueta>EDITORIAL</Etiqueta>
|
|
104
|
+
<Titulo>{props.titulo}</Titulo>
|
|
105
|
+
<p className="m-0 max-w-[420px] text-sm leading-relaxed" style={{ color: f.colorSuave }}>
|
|
106
|
+
{props.texto}
|
|
107
|
+
</p>
|
|
108
|
+
<div>
|
|
109
|
+
<BotonSecundario texto={props.enlace.texto} href={props.enlace.href} ctx={ctx} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Derecha({ props, ctx }: { props: EditorialBannerProps; ctx: RenderContext }) {
|
|
116
|
+
const { Imagen } = ctx.componentes
|
|
117
|
+
const f = resolverFondo(props.fondo)
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className="grid w-full grid-cols-1 overflow-hidden md:grid-cols-[1.1fr_1fr]"
|
|
121
|
+
style={{
|
|
122
|
+
background: f.background,
|
|
123
|
+
color: f.color,
|
|
124
|
+
border: `var(--cms-grosor-borde) solid ${f.borde}`,
|
|
125
|
+
borderRadius: "var(--cms-radio)",
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<Contenido props={props} ctx={ctx} className="order-2 md:order-1" />
|
|
129
|
+
<Imagen
|
|
130
|
+
src={props.imagenUrl}
|
|
131
|
+
alt={props.titulo}
|
|
132
|
+
className="order-1 aspect-[4/3] w-full object-cover md:order-2 md:aspect-auto md:min-h-[320px]"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function Izquierda({ props, ctx }: { props: EditorialBannerProps; ctx: RenderContext }) {
|
|
139
|
+
const { Imagen } = ctx.componentes
|
|
140
|
+
const f = resolverFondo(props.fondo)
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className="grid w-full grid-cols-1 overflow-hidden md:grid-cols-[1fr_1.1fr]"
|
|
144
|
+
style={{
|
|
145
|
+
background: f.background,
|
|
146
|
+
color: f.color,
|
|
147
|
+
border: `var(--cms-grosor-borde) solid ${f.borde}`,
|
|
148
|
+
borderRadius: "var(--cms-radio)",
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<Imagen
|
|
152
|
+
src={props.imagenUrl}
|
|
153
|
+
alt={props.titulo}
|
|
154
|
+
className="aspect-[4/3] w-full object-cover md:aspect-auto md:min-h-[320px]"
|
|
155
|
+
/>
|
|
156
|
+
<Contenido props={props} ctx={ctx} />
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function Fondo({ props, ctx }: { props: EditorialBannerProps; ctx: RenderContext }) {
|
|
162
|
+
const { Imagen } = ctx.componentes
|
|
163
|
+
return (
|
|
164
|
+
<div className="relative min-h-[320px] w-full overflow-hidden md:min-h-[420px]" style={{ borderRadius: "var(--cms-radio)" }}>
|
|
165
|
+
<Imagen
|
|
166
|
+
src={props.imagenUrl}
|
|
167
|
+
alt={props.titulo}
|
|
168
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
169
|
+
/>
|
|
170
|
+
<div
|
|
171
|
+
className="absolute inset-x-0 bottom-0 flex flex-col gap-3 px-6 pb-8 pt-16 text-white md:gap-3.5 md:px-12 md:pb-10 md:pt-24"
|
|
172
|
+
style={{ background: "linear-gradient(transparent, rgba(0,0,0,.72))" }}
|
|
173
|
+
>
|
|
174
|
+
<Etiqueta>EDITORIAL</Etiqueta>
|
|
175
|
+
<Titulo className="text-white">{props.titulo}</Titulo>
|
|
176
|
+
<p className="m-0 max-w-[420px] text-sm leading-relaxed text-white/80">{props.texto}</p>
|
|
177
|
+
<div>
|
|
178
|
+
<BotonSecundario
|
|
179
|
+
texto={props.enlace.texto}
|
|
180
|
+
href={props.enlace.href}
|
|
181
|
+
ctx={ctx}
|
|
182
|
+
className="border-white text-white"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const editorialBannerBlock: BlockDefinition<EditorialBannerProps, RenderContext> = {
|
|
191
|
+
label: "Banda editorial",
|
|
192
|
+
schema: editorialBannerSchema,
|
|
193
|
+
defaults: editorialBannerDefaults,
|
|
194
|
+
Component: ({ props, ctx }) => {
|
|
195
|
+
switch (props.posicionImagen) {
|
|
196
|
+
case "izquierda":
|
|
197
|
+
return <Izquierda props={props} ctx={ctx} />
|
|
198
|
+
case "fondo":
|
|
199
|
+
return <Fondo props={props} ctx={ctx} />
|
|
200
|
+
case "derecha":
|
|
201
|
+
default:
|
|
202
|
+
return <Derecha props={props} ctx={ctx} />
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import { faqBlock } from "./faq"
|
|
4
|
+
import { FONDOS } from "./_shared"
|
|
5
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
6
|
+
|
|
7
|
+
describe("faqBlock", () => {
|
|
8
|
+
it("defaults pasan el schema", () => {
|
|
9
|
+
expect(faqBlock.schema.safeParse(faqBlock.defaults).success).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
|
|
13
|
+
const props = { ...faqBlock.defaults, fondo }
|
|
14
|
+
expect(faqBlock.schema.safeParse(props).success).toBe(true)
|
|
15
|
+
const html = renderToStaticMarkup(<faqBlock.Component props={props} ctx={mockCtx()} />)
|
|
16
|
+
for (const item of faqBlock.defaults.items) {
|
|
17
|
+
expect(html).toContain(item.pregunta)
|
|
18
|
+
expect(html).toContain(item.respuesta)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("usa <details>/<summary> nativos (accesible, sin JS)", () => {
|
|
23
|
+
const html = renderToStaticMarkup(<faqBlock.Component props={faqBlock.defaults} ctx={mockCtx()} />)
|
|
24
|
+
expect((html.match(/<details/g) ?? []).length).toBe(faqBlock.defaults.items.length)
|
|
25
|
+
expect((html.match(/<summary/g) ?? []).length).toBe(faqBlock.defaults.items.length)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("renderiza un item por pregunta (editable)", () => {
|
|
29
|
+
const props = {
|
|
30
|
+
fondo: "claro" as const,
|
|
31
|
+
titulo: "FAQ",
|
|
32
|
+
items: [
|
|
33
|
+
{ pregunta: "P1", respuesta: "R1" },
|
|
34
|
+
{ pregunta: "P2", respuesta: "R2" },
|
|
35
|
+
{ pregunta: "P3", respuesta: "R3" },
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
const html = renderToStaticMarkup(<faqBlock.Component props={props} ctx={mockCtx()} />)
|
|
39
|
+
expect((html.match(/<details/g) ?? []).length).toBe(3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("item sin pregunta o respuesta no pasa el schema", () => {
|
|
43
|
+
expect(
|
|
44
|
+
faqBlock.schema.safeParse({ fondo: "claro", items: [{ pregunta: "", respuesta: "x" }] }).success
|
|
45
|
+
).toBe(false)
|
|
46
|
+
expect(
|
|
47
|
+
faqBlock.schema.safeParse({ fondo: "claro", items: [{ pregunta: "x", respuesta: "" }] }).success
|
|
48
|
+
).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("titulo opcional: sin titulo no pinta <h2>", () => {
|
|
52
|
+
const html = renderToStaticMarkup(
|
|
53
|
+
<faqBlock.Component props={{ fondo: "claro", items: [{ pregunta: "P", respuesta: "R" }] }} ctx={mockCtx()} />
|
|
54
|
+
)
|
|
55
|
+
expect(html).not.toContain("<h2")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("respeta prefers-reduced-motion (apaga la transición del chevron)", () => {
|
|
59
|
+
const html = renderToStaticMarkup(<faqBlock.Component props={faqBlock.defaults} ctx={mockCtx()} />)
|
|
60
|
+
expect(html).toContain("prefers-reduced-motion: reduce")
|
|
61
|
+
expect(html).toContain("transition:none")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("no usa <h1>", () => {
|
|
65
|
+
const html = renderToStaticMarkup(<faqBlock.Component props={faqBlock.defaults} ctx={mockCtx()} />)
|
|
66
|
+
expect(html).not.toMatch(/<h1[ >]/)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
* Preguntas frecuentes: acordeón accesible construido con `<details>/<summary>`
|
|
8
|
+
* nativos — sin JS, funciona con teclado y lectores de pantalla por defecto. El
|
|
9
|
+
* chevron rota con una transición sutil que se apaga bajo `prefers-reduced-motion`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type FaqItem = {
|
|
13
|
+
pregunta: string
|
|
14
|
+
respuesta: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type FaqProps = {
|
|
18
|
+
fondo: Fondo
|
|
19
|
+
titulo?: string
|
|
20
|
+
items: FaqItem[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const faqItemSchema: z.ZodType<FaqItem> = z.object({
|
|
24
|
+
pregunta: z.string().min(1),
|
|
25
|
+
respuesta: z.string().min(1),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const faqSchema: z.ZodType<FaqProps> = z.object({
|
|
29
|
+
fondo: z.enum(FONDOS),
|
|
30
|
+
titulo: z.string().optional(),
|
|
31
|
+
items: z.array(faqItemSchema),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const faqDefaults: FaqProps = {
|
|
35
|
+
fondo: "claro",
|
|
36
|
+
titulo: "Preguntas frecuentes",
|
|
37
|
+
items: [
|
|
38
|
+
{
|
|
39
|
+
pregunta: "¿Cuánto tarda el envío?",
|
|
40
|
+
respuesta: "Despachamos en 24–48h hábiles a toda Colombia. El envío es gratis desde $150.000.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pregunta: "¿Puedo cambiar o devolver una prenda?",
|
|
44
|
+
respuesta: "Sí, tenés 30 días para cambios y devoluciones. Escribinos por chat o WhatsApp y lo gestionamos.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
pregunta: "¿Qué métodos de pago aceptan?",
|
|
48
|
+
respuesta: "Aceptamos PSE, tarjeta de crédito/débito y pago en efectivo contra entrega en ciudades principales.",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
pregunta: "¿Cómo elijo mi talla?",
|
|
52
|
+
respuesta: "Cada producto tiene su guía de tallas. Ante la duda, escribinos y te ayudamos a elegir.",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** CSS del chevron: rota al abrir; se desactiva bajo prefers-reduced-motion. */
|
|
58
|
+
const CSS_FAQ =
|
|
59
|
+
".cms-faq summary{list-style:none;cursor:pointer}" +
|
|
60
|
+
".cms-faq summary::-webkit-details-marker{display:none}" +
|
|
61
|
+
".cms-faq-chevron{transition:transform .25s ease}" +
|
|
62
|
+
".cms-faq[open] .cms-faq-chevron{transform:rotate(180deg)}" +
|
|
63
|
+
"@media (prefers-reduced-motion: reduce){.cms-faq-chevron{transition:none}}"
|
|
64
|
+
|
|
65
|
+
function Chevron({ color }: { color: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<svg
|
|
68
|
+
className="cms-faq-chevron shrink-0"
|
|
69
|
+
viewBox="0 0 24 24"
|
|
70
|
+
width="18"
|
|
71
|
+
height="18"
|
|
72
|
+
fill="none"
|
|
73
|
+
stroke={color}
|
|
74
|
+
strokeWidth={1.8}
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
focusable="false"
|
|
79
|
+
>
|
|
80
|
+
<path d="M6 9l6 6 6-6" />
|
|
81
|
+
</svg>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const faqBlock: BlockDefinition<FaqProps, RenderContext> = {
|
|
86
|
+
label: "Preguntas frecuentes",
|
|
87
|
+
schema: faqSchema,
|
|
88
|
+
defaults: faqDefaults,
|
|
89
|
+
Component: ({ props }) => {
|
|
90
|
+
const f = resolverFondo(props.fondo)
|
|
91
|
+
return (
|
|
92
|
+
<Seccion fondo={props.fondo} ariaLabel={props.titulo || "Preguntas frecuentes"}>
|
|
93
|
+
<style dangerouslySetInnerHTML={{ __html: CSS_FAQ }} />
|
|
94
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
95
|
+
{props.titulo ? <TituloSeccion className="mb-8 text-center">{props.titulo}</TituloSeccion> : null}
|
|
96
|
+
<div className="flex flex-col">
|
|
97
|
+
{props.items.map((item, i) => (
|
|
98
|
+
<details
|
|
99
|
+
key={i}
|
|
100
|
+
className="cms-faq group"
|
|
101
|
+
style={{ borderTop: `var(--cms-grosor-borde) solid ${f.borde}` }}
|
|
102
|
+
>
|
|
103
|
+
<summary className="flex items-center justify-between gap-4 py-5">
|
|
104
|
+
<span
|
|
105
|
+
className="text-[15px] font-semibold md:text-base"
|
|
106
|
+
style={{ fontFamily: "var(--cms-fuente-texto)" }}
|
|
107
|
+
>
|
|
108
|
+
{item.pregunta}
|
|
109
|
+
</span>
|
|
110
|
+
<Chevron color={f.acento} />
|
|
111
|
+
</summary>
|
|
112
|
+
<p className="m-0 pb-5 text-[14px] leading-relaxed" style={{ color: f.colorSuave }}>
|
|
113
|
+
{item.respuesta}
|
|
114
|
+
</p>
|
|
115
|
+
</details>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</Seccion>
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
4
|
+
import { featuredCollectionBlock } from "./featured-collection"
|
|
5
|
+
import { FONDOS } from "./_shared"
|
|
6
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
7
|
+
|
|
8
|
+
const productos = [
|
|
9
|
+
{ id: "p1", title: "Sneaker Runner", handle: "sneaker-runner", thumbnail: null, collection_id: "col_1", variants: [] },
|
|
10
|
+
{ id: "p2", title: "Hoodie Heavy", handle: "hoodie-heavy", thumbnail: null, collection_id: "col_2", variants: [] },
|
|
11
|
+
] as unknown as HttpTypes.StoreProduct[]
|
|
12
|
+
|
|
13
|
+
describe("featuredCollectionBlock", () => {
|
|
14
|
+
it("defaults pasan el schema", () => {
|
|
15
|
+
expect(featuredCollectionBlock.schema.safeParse(featuredCollectionBlock.defaults).success).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
|
|
19
|
+
const props = { ...featuredCollectionBlock.defaults, fondo }
|
|
20
|
+
expect(featuredCollectionBlock.schema.safeParse(props).success).toBe(true)
|
|
21
|
+
const html = renderToStaticMarkup(
|
|
22
|
+
<featuredCollectionBlock.Component props={props} ctx={mockCtx({ products: productos })} />
|
|
23
|
+
)
|
|
24
|
+
expect(html).toContain(featuredCollectionBlock.defaults.titulo)
|
|
25
|
+
expect(html).toContain("Sneaker Runner")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("es un carrusel con snap horizontal", () => {
|
|
29
|
+
const html = renderToStaticMarkup(
|
|
30
|
+
<featuredCollectionBlock.Component
|
|
31
|
+
props={featuredCollectionBlock.defaults}
|
|
32
|
+
ctx={mockCtx({ products: productos })}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
expect(html).toContain("snap-x")
|
|
36
|
+
expect(html).toContain("snap-start")
|
|
37
|
+
expect(html).toContain("overflow-x-auto")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("filtra por coleccionId cuando se indica", () => {
|
|
41
|
+
const props = { ...featuredCollectionBlock.defaults, coleccionId: "col_1" }
|
|
42
|
+
const html = renderToStaticMarkup(
|
|
43
|
+
<featuredCollectionBlock.Component props={props} ctx={mockCtx({ products: productos })} />
|
|
44
|
+
)
|
|
45
|
+
expect(html).toContain("Sneaker Runner")
|
|
46
|
+
expect(html).not.toContain("Hoodie Heavy")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("muestra el enlace 'ver todos'", () => {
|
|
50
|
+
const html = renderToStaticMarkup(
|
|
51
|
+
<featuredCollectionBlock.Component
|
|
52
|
+
props={featuredCollectionBlock.defaults}
|
|
53
|
+
ctx={mockCtx({ products: productos })}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
expect(html).toContain(featuredCollectionBlock.defaults.verTodos.texto)
|
|
57
|
+
expect(html).toContain(`href="${featuredCollectionBlock.defaults.verTodos.href}"`)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("sin productos muestra estado vacío en vez de romper", () => {
|
|
61
|
+
const html = renderToStaticMarkup(
|
|
62
|
+
<featuredCollectionBlock.Component
|
|
63
|
+
props={featuredCollectionBlock.defaults}
|
|
64
|
+
ctx={mockCtx({ products: [] })}
|
|
65
|
+
/>
|
|
66
|
+
)
|
|
67
|
+
expect(html).toContain("Pronto verás productos")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("titulo es <h2>, nunca <h1>", () => {
|
|
71
|
+
const html = renderToStaticMarkup(
|
|
72
|
+
<featuredCollectionBlock.Component
|
|
73
|
+
props={featuredCollectionBlock.defaults}
|
|
74
|
+
ctx={mockCtx({ products: productos })}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
expect(html).toContain("<h2")
|
|
78
|
+
expect(html).not.toMatch(/<h1[ >]/)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
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 { TarjetaProducto, type CardVariant } from "../catalog/product-card"
|
|
6
|
+
import { CLASE_REVEAL, Seccion, TituloSeccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
|
|
7
|
+
import { Carrusel } from "./carrusel"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Carrusel horizontal de productos (distinto de la grilla estática): slider con
|
|
11
|
+
* flechas + snap, scrollbar oculto. Reusa `TarjetaProducto`. `coleccionId`
|
|
12
|
+
* filtra `ctx.products`.
|
|
13
|
+
*
|
|
14
|
+
* Nota: `coleccionId` es texto (igual que `productGrid`) porque los campos de
|
|
15
|
+
* Puck son estáticos y no tienen acceso a `ctx` para poblar un picker en vivo.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const CARDS_FEATURED = ["minimal", "quick-add", "tallas", "quick-view"] as const
|
|
19
|
+
|
|
20
|
+
export type FeaturedCollectionProps = {
|
|
21
|
+
fondo: Fondo
|
|
22
|
+
titulo: string
|
|
23
|
+
coleccionId: string
|
|
24
|
+
card: CardVariant
|
|
25
|
+
verTodos: { texto: string; href: string }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const featuredCollectionSchema: z.ZodType<FeaturedCollectionProps> = z.object({
|
|
29
|
+
fondo: z.enum(FONDOS),
|
|
30
|
+
titulo: z.string().min(1),
|
|
31
|
+
coleccionId: z.string(),
|
|
32
|
+
card: z.enum(CARDS_FEATURED),
|
|
33
|
+
verTodos: linkSchema,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const featuredCollectionDefaults: FeaturedCollectionProps = {
|
|
37
|
+
fondo: "claro",
|
|
38
|
+
titulo: "Lo más buscado",
|
|
39
|
+
coleccionId: "",
|
|
40
|
+
card: "minimal",
|
|
41
|
+
verTodos: { texto: "Ver todo", href: "/catalogo" },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const featuredCollectionBlock: BlockDefinition<FeaturedCollectionProps, RenderContext> = {
|
|
45
|
+
label: "Colección destacada",
|
|
46
|
+
schema: featuredCollectionSchema,
|
|
47
|
+
defaults: featuredCollectionDefaults,
|
|
48
|
+
Component: ({ props, ctx }) => {
|
|
49
|
+
const f = resolverFondo(props.fondo)
|
|
50
|
+
const { Enlace } = ctx.componentes
|
|
51
|
+
const productos = props.coleccionId
|
|
52
|
+
? ctx.products.filter((product) => product.collection_id === props.coleccionId)
|
|
53
|
+
: ctx.products
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Seccion fondo={props.fondo} ariaLabel={props.titulo}>
|
|
57
|
+
<div className="mx-auto w-full max-w-6xl">
|
|
58
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
59
|
+
<TituloSeccion>{props.titulo}</TituloSeccion>
|
|
60
|
+
{props.verTodos.texto ? (
|
|
61
|
+
<Enlace
|
|
62
|
+
href={props.verTodos.href}
|
|
63
|
+
className="shrink-0 text-[12px] font-semibold uppercase tracking-[0.08em] underline underline-offset-4"
|
|
64
|
+
>
|
|
65
|
+
{props.verTodos.texto}
|
|
66
|
+
</Enlace>
|
|
67
|
+
) : null}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{productos.length ? (
|
|
71
|
+
<div className="mt-6">
|
|
72
|
+
<Carrusel
|
|
73
|
+
ariaLabel={props.titulo}
|
|
74
|
+
colorFlecha={f.color}
|
|
75
|
+
bordeFlecha={f.borde}
|
|
76
|
+
fondoFlecha={f.background}
|
|
77
|
+
>
|
|
78
|
+
{productos.map((product) => (
|
|
79
|
+
<li
|
|
80
|
+
key={product.id}
|
|
81
|
+
className={`${CLASE_REVEAL} w-[62vw] shrink-0 snap-start sm:w-[240px] md:w-[260px]`}
|
|
82
|
+
>
|
|
83
|
+
<TarjetaProducto product={product} card={props.card} ctx={ctx} />
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</Carrusel>
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
<p className="mt-6 text-[13px]" style={{ color: f.colorSuave }}>
|
|
90
|
+
Pronto verás productos en esta colección.
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</Seccion>
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { featuresBlock, ICONOS_FEATURE } from "./features"
|
|
6
|
+
import { FONDOS } from "./_shared"
|
|
7
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
8
|
+
|
|
9
|
+
describe("featuresBlock", () => {
|
|
10
|
+
it("defaults pasan el schema", () => {
|
|
11
|
+
expect(featuresBlock.schema.safeParse(featuresBlock.defaults).success).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each(FONDOS)("renderiza sin crash con fondo %s", (fondo) => {
|
|
15
|
+
const props = { ...featuresBlock.defaults, fondo }
|
|
16
|
+
expect(featuresBlock.schema.safeParse(props).success).toBe(true)
|
|
17
|
+
|
|
18
|
+
const html = renderToStaticMarkup(<featuresBlock.Component props={props} ctx={mockCtx()} />)
|
|
19
|
+
|
|
20
|
+
for (const item of featuresBlock.defaults.items) {
|
|
21
|
+
expect(html).toContain(item.titulo)
|
|
22
|
+
expect(html).toContain(item.texto)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("renderiza un item por cada entrada del array (editable)", () => {
|
|
27
|
+
const props = {
|
|
28
|
+
fondo: "claro" as const,
|
|
29
|
+
items: [
|
|
30
|
+
{ icono: "envio" as const, titulo: "Uno", texto: "T1" },
|
|
31
|
+
{ icono: "calidad" as const, titulo: "Dos", texto: "T2" },
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
const html = renderToStaticMarkup(<featuresBlock.Component props={props} ctx={mockCtx()} />)
|
|
35
|
+
expect(html).toContain("Uno")
|
|
36
|
+
expect(html).toContain("Dos")
|
|
37
|
+
expect((html.match(/<svg/g) ?? []).length).toBe(2)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("acepta todos los iconos del enum", () => {
|
|
41
|
+
for (const icono of ICONOS_FEATURE) {
|
|
42
|
+
const props = { fondo: "claro" as const, items: [{ icono, titulo: "X", texto: "y" }] }
|
|
43
|
+
expect(featuresBlock.schema.safeParse(props).success).toBe(true)
|
|
44
|
+
const html = renderToStaticMarkup(<featuresBlock.Component props={props} ctx={mockCtx()} />)
|
|
45
|
+
expect(html).toContain("<svg")
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("los titulos son <h3>, nunca <h1> (ese heading es del hero)", () => {
|
|
50
|
+
const html = renderToStaticMarkup(
|
|
51
|
+
<featuresBlock.Component props={featuresBlock.defaults} ctx={mockCtx()} />
|
|
52
|
+
)
|
|
53
|
+
expect(html).not.toMatch(/<h1[ >]/)
|
|
54
|
+
expect(html).toContain("<h3")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("emite motion respetando prefers-reduced-motion", () => {
|
|
58
|
+
const html = renderToStaticMarkup(
|
|
59
|
+
<featuresBlock.Component props={featuresBlock.defaults} ctx={mockCtx()} />
|
|
60
|
+
)
|
|
61
|
+
expect(html).toContain("prefers-reduced-motion")
|
|
62
|
+
expect(html).toContain("cms-reveal")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("no emite <img> ni <a> directos en el fuente", () => {
|
|
66
|
+
const ruta = fileURLToPath(new URL("./features.tsx", import.meta.url))
|
|
67
|
+
const fuente = readFileSync(ruta, "utf-8")
|
|
68
|
+
expect(fuente).not.toMatch(/<img[\s>]/)
|
|
69
|
+
expect(fuente).not.toMatch(/<a[\s>]/)
|
|
70
|
+
})
|
|
71
|
+
})
|