@arroyavecommerce/theme-kinetic 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/docs/design/README.md +22 -0
  2. package/docs/design/kinetic-street-concept.html +558 -0
  3. package/package.json +45 -0
  4. package/src/blocks/_shared.tsx +174 -0
  5. package/src/blocks/carrusel.tsx +109 -0
  6. package/src/blocks/category-tiles.test.tsx +59 -0
  7. package/src/blocks/category-tiles.tsx +70 -0
  8. package/src/blocks/editorial-banner.test.tsx +96 -0
  9. package/src/blocks/editorial-banner.tsx +205 -0
  10. package/src/blocks/faq.test.tsx +68 -0
  11. package/src/blocks/faq.tsx +122 -0
  12. package/src/blocks/featured-collection.test.tsx +80 -0
  13. package/src/blocks/featured-collection.tsx +97 -0
  14. package/src/blocks/features.test.tsx +71 -0
  15. package/src/blocks/features.tsx +159 -0
  16. package/src/blocks/hero.test.tsx +75 -0
  17. package/src/blocks/hero.tsx +290 -0
  18. package/src/blocks/index.tsx +35 -0
  19. package/src/blocks/lookbook.test.tsx +80 -0
  20. package/src/blocks/lookbook.tsx +117 -0
  21. package/src/blocks/marquee.test.tsx +54 -0
  22. package/src/blocks/marquee.tsx +114 -0
  23. package/src/blocks/newsletter.test.tsx +60 -0
  24. package/src/blocks/newsletter.tsx +105 -0
  25. package/src/blocks/product-grid.test.tsx +160 -0
  26. package/src/blocks/product-grid.tsx +76 -0
  27. package/src/blocks/promo-countdown.tsx +75 -0
  28. package/src/blocks/promo.test.tsx +71 -0
  29. package/src/blocks/promo.tsx +120 -0
  30. package/src/blocks/rich-text.test.tsx +67 -0
  31. package/src/blocks/rich-text.tsx +75 -0
  32. package/src/blocks/spacer.test.tsx +54 -0
  33. package/src/blocks/spacer.tsx +56 -0
  34. package/src/blocks/testimonials.test.tsx +74 -0
  35. package/src/blocks/testimonials.tsx +104 -0
  36. package/src/blocks/video.test.tsx +159 -0
  37. package/src/blocks/video.tsx +226 -0
  38. package/src/catalog/buy-box.test.tsx +225 -0
  39. package/src/catalog/buy-box.tsx +353 -0
  40. package/src/catalog/config.test.ts +115 -0
  41. package/src/catalog/config.ts +167 -0
  42. package/src/catalog/filter-controls.test.tsx +149 -0
  43. package/src/catalog/filter-controls.tsx +187 -0
  44. package/src/catalog/gallery.test.tsx +201 -0
  45. package/src/catalog/gallery.tsx +212 -0
  46. package/src/catalog/lightbox.test.tsx +95 -0
  47. package/src/catalog/lightbox.tsx +184 -0
  48. package/src/catalog/pdp.test.tsx +309 -0
  49. package/src/catalog/pdp.tsx +354 -0
  50. package/src/catalog/plp-arreglos.test.tsx +235 -0
  51. package/src/catalog/plp-inline.tsx +54 -0
  52. package/src/catalog/plp-overlay.tsx +129 -0
  53. package/src/catalog/plp-parts.test.tsx +146 -0
  54. package/src/catalog/plp-parts.tsx +292 -0
  55. package/src/catalog/plp-pills.tsx +44 -0
  56. package/src/catalog/plp-sidebar.tsx +47 -0
  57. package/src/catalog/plp.test.tsx +319 -0
  58. package/src/catalog/plp.tsx +259 -0
  59. package/src/catalog/product-card.test.tsx +221 -0
  60. package/src/catalog/product-card.tsx +411 -0
  61. package/src/catalog/registry.tsx +16 -0
  62. package/src/catalog/related.test.tsx +52 -0
  63. package/src/catalog/related.tsx +40 -0
  64. package/src/catalog/reviews.test.tsx +56 -0
  65. package/src/catalog/reviews.tsx +116 -0
  66. package/src/catalog/skeletons.test.tsx +61 -0
  67. package/src/catalog/skeletons.tsx +103 -0
  68. package/src/chrome/announcement-bar.test.tsx +86 -0
  69. package/src/chrome/announcement-bar.tsx +173 -0
  70. package/src/chrome/drawer-movil.test.tsx +113 -0
  71. package/src/chrome/drawer-movil.tsx +342 -0
  72. package/src/chrome/footer.test.tsx +133 -0
  73. package/src/chrome/footer.tsx +603 -0
  74. package/src/chrome/header.test.tsx +165 -0
  75. package/src/chrome/header.tsx +592 -0
  76. package/src/chrome/index.ts +26 -0
  77. package/src/chrome/mega-menu.test.tsx +161 -0
  78. package/src/chrome/mega-menu.tsx +317 -0
  79. package/src/chrome/mini-cart.test.tsx +111 -0
  80. package/src/chrome/mini-cart.tsx +320 -0
  81. package/src/content/home.ts +19 -0
  82. package/src/content/seed.test.ts +33 -0
  83. package/src/content/seed.ts +28 -0
  84. package/src/context.ts +104 -0
  85. package/src/icons/icons.test.tsx +118 -0
  86. package/src/icons/index.ts +136 -0
  87. package/src/icons/payment-icons.tsx +241 -0
  88. package/src/icons/social-icons.tsx +108 -0
  89. package/src/icons/svg-base.tsx +68 -0
  90. package/src/index.ts +49 -0
  91. package/src/puck-config.tsx +404 -0
  92. package/src/smoke.test.ts +8 -0
  93. package/src/template.test.tsx +88 -0
  94. package/src/test/mock-ctx.test.tsx +77 -0
  95. package/src/test/mock-ctx.tsx +236 -0
  96. package/src/theme-defaults.test.ts +9 -0
  97. package/src/theme.test.ts +82 -0
  98. package/src/theme.ts +195 -0
  99. package/tsconfig.json +4 -0
@@ -0,0 +1,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
+ })