@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,117 @@
1
+ import { z } from "zod"
2
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
3
+ import type { RenderContext } from "../context"
4
+ import { CLASE_REVEAL, Seccion, TituloSeccion, resolverFondo, type Fondo, FONDOS } from "./_shared"
5
+
6
+ /**
7
+ * Lookbook: galería editorial de moda. Mosaico de imágenes (2–4 columnas según
8
+ * cantidad) con título e enlace opcionales por pieza. El primer item se destaca
9
+ * (ocupa 2 columnas en pantallas grandes) para dar ritmo editorial. Las imágenes
10
+ * pasan por `ctx.componentes.Imagen`, que ya pinta un placeholder accesible
11
+ * cuando la URL viene vacía. El bloque de storytelling de moda de la home.
12
+ */
13
+
14
+ export type LookbookItem = {
15
+ imagenUrl: string
16
+ titulo?: string
17
+ href?: string
18
+ }
19
+
20
+ export type LookbookProps = {
21
+ fondo: Fondo
22
+ titulo?: string
23
+ items: LookbookItem[]
24
+ }
25
+
26
+ const lookbookItemSchema: z.ZodType<LookbookItem> = z.object({
27
+ imagenUrl: z.string(),
28
+ titulo: z.string().optional(),
29
+ href: z.string().optional(),
30
+ })
31
+
32
+ const lookbookSchema: z.ZodType<LookbookProps> = z.object({
33
+ fondo: z.enum(FONDOS),
34
+ titulo: z.string().optional(),
35
+ items: z.array(lookbookItemSchema),
36
+ })
37
+
38
+ const lookbookDefaults: LookbookProps = {
39
+ fondo: "claro",
40
+ titulo: "El lookbook",
41
+ items: [
42
+ { imagenUrl: "", titulo: "Uniforme urbano", href: "/collections/urbano" },
43
+ { imagenUrl: "", titulo: "Básicos esenciales", href: "/collections/basicos" },
44
+ { imagenUrl: "", titulo: "Drop de temporada", href: "/collections/temporada" },
45
+ { imagenUrl: "", titulo: "Accesorios", href: "/collections/accesorios" },
46
+ ],
47
+ }
48
+
49
+ /** Un mosaico: imagen (con placeholder si vacía) + título opcional en overlay. */
50
+ function Mosaico({
51
+ item,
52
+ ctx,
53
+ destacado,
54
+ }: {
55
+ item: LookbookItem
56
+ ctx: RenderContext
57
+ destacado?: boolean
58
+ }) {
59
+ const { Imagen, Enlace } = ctx.componentes
60
+ const contenido = (
61
+ <figure
62
+ className={`${CLASE_REVEAL} group relative m-0 h-full w-full overflow-hidden`}
63
+ style={{ borderRadius: "var(--cms-radio-md,12px)" }}
64
+ >
65
+ <Imagen
66
+ src={item.imagenUrl}
67
+ alt={item.titulo ?? ""}
68
+ className={`${destacado ? "aspect-[4/5] md:aspect-auto md:h-full" : "aspect-[3/4]"} w-full object-cover [transition:transform_.5s_ease] group-hover:scale-[1.04]`}
69
+ />
70
+ {item.titulo ? (
71
+ <figcaption
72
+ className="absolute inset-x-0 bottom-0 flex items-end px-4 pb-4 pt-14 text-white"
73
+ style={{ background: "linear-gradient(transparent, rgba(0,0,0,.62))" }}
74
+ >
75
+ <span
76
+ className="text-[13px] font-semibold uppercase tracking-[0.1em]"
77
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
78
+ >
79
+ {item.titulo}
80
+ </span>
81
+ </figcaption>
82
+ ) : null}
83
+ </figure>
84
+ )
85
+
86
+ const claseCelda = destacado ? "md:col-span-2 md:row-span-2" : ""
87
+ if (item.href) {
88
+ return (
89
+ <Enlace href={item.href} className={`block ${claseCelda}`}>
90
+ {contenido}
91
+ </Enlace>
92
+ )
93
+ }
94
+ return <div className={claseCelda}>{contenido}</div>
95
+ }
96
+
97
+ export const lookbookBlock: BlockDefinition<LookbookProps, RenderContext> = {
98
+ label: "Lookbook",
99
+ schema: lookbookSchema,
100
+ defaults: lookbookDefaults,
101
+ Component: ({ props, ctx }) => {
102
+ // 2 columnas base; 3 o 4 según cantidad para el mosaico editorial.
103
+ const cols = props.items.length >= 4 ? "lg:grid-cols-4" : props.items.length === 3 ? "lg:grid-cols-3" : "lg:grid-cols-2"
104
+ return (
105
+ <Seccion fondo={props.fondo} ariaLabel={props.titulo || "Lookbook"}>
106
+ <div className="mx-auto w-full max-w-6xl">
107
+ {props.titulo ? <TituloSeccion className="mb-8">{props.titulo}</TituloSeccion> : null}
108
+ <div className={`grid auto-rows-[1fr] grid-cols-2 gap-3 md:gap-4 ${cols}`}>
109
+ {props.items.map((item, i) => (
110
+ <Mosaico key={i} item={item} ctx={ctx} destacado={i === 0 && props.items.length >= 3} />
111
+ ))}
112
+ </div>
113
+ </div>
114
+ </Seccion>
115
+ )
116
+ },
117
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { marqueeBlock, COLORES_MARQUEE, VELOCIDADES } from "./marquee"
4
+ import { mockCtx } from "../test/mock-ctx"
5
+
6
+ describe("marqueeBlock", () => {
7
+ it("defaults pasan el schema", () => {
8
+ expect(marqueeBlock.schema.safeParse(marqueeBlock.defaults).success).toBe(true)
9
+ })
10
+
11
+ it.each(COLORES_MARQUEE)("renderiza sin crash con color %s", (color) => {
12
+ const props = { ...marqueeBlock.defaults, color }
13
+ expect(marqueeBlock.schema.safeParse(props).success).toBe(true)
14
+ const html = renderToStaticMarkup(<marqueeBlock.Component props={props} ctx={mockCtx()} />)
15
+ for (const mensaje of marqueeBlock.defaults.mensajes) {
16
+ expect(html).toContain(mensaje.texto)
17
+ }
18
+ })
19
+
20
+ it.each(VELOCIDADES)("aplica una duracion por velocidad %s", (velocidad) => {
21
+ const props = { ...marqueeBlock.defaults, velocidad }
22
+ const html = renderToStaticMarkup(<marqueeBlock.Component props={props} ctx={mockCtx()} />)
23
+ // La duracion vive en el keyframe track (16s/28s/42s).
24
+ expect(html).toMatch(/animation:[^;]*\d+s linear infinite/)
25
+ })
26
+
27
+ it("duplica la tira (mensajes salen 2 veces) para el loop sin costuras", () => {
28
+ const props = { ...marqueeBlock.defaults, mensajes: [{ texto: "SOLO" }] }
29
+ const html = renderToStaticMarkup(<marqueeBlock.Component props={props} ctx={mockCtx()} />)
30
+ expect((html.match(/SOLO/g) ?? []).length).toBe(2)
31
+ })
32
+
33
+ it("respeta prefers-reduced-motion (animation:none)", () => {
34
+ const html = renderToStaticMarkup(
35
+ <marqueeBlock.Component props={marqueeBlock.defaults} ctx={mockCtx()} />
36
+ )
37
+ expect(html).toContain("prefers-reduced-motion: reduce")
38
+ expect(html).toContain("animation:none")
39
+ })
40
+
41
+ it("con mensajes vacios cae a los mensajes por defecto", () => {
42
+ const props = { ...marqueeBlock.defaults, mensajes: [] }
43
+ expect(marqueeBlock.schema.safeParse(props).success).toBe(true)
44
+ const html = renderToStaticMarkup(<marqueeBlock.Component props={props} ctx={mockCtx()} />)
45
+ expect(html).toContain(marqueeBlock.defaults.mensajes[0].texto)
46
+ })
47
+
48
+ it("no usa <h1>", () => {
49
+ const html = renderToStaticMarkup(
50
+ <marqueeBlock.Component props={marqueeBlock.defaults} ctx={mockCtx()} />
51
+ )
52
+ expect(html).not.toMatch(/<h1[ >]/)
53
+ })
54
+ })
@@ -0,0 +1,114 @@
1
+ import { useId } from "react"
2
+ import { z } from "zod"
3
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
4
+ import type { RenderContext } from "../context"
5
+
6
+ /**
7
+ * Banda de texto en movimiento (scroll infinito CSS). El color de la banda es
8
+ * su propia superficie (acento / tinta / degradado), por eso NO usa el enum
9
+ * genérico `fondo`: la banda ES el color. Respeta `prefers-reduced-motion`
10
+ * (queda estática y centrada cuando el usuario pide menos movimiento).
11
+ */
12
+
13
+ export const VELOCIDADES = ["lenta", "media", "rapida"] as const
14
+ export type Velocidad = (typeof VELOCIDADES)[number]
15
+
16
+ export const COLORES_MARQUEE = ["acento", "tinta", "degradado"] as const
17
+ export type ColorMarquee = (typeof COLORES_MARQUEE)[number]
18
+
19
+ export type MarqueeMensaje = { texto: string }
20
+
21
+ export type MarqueeProps = {
22
+ mensajes: MarqueeMensaje[]
23
+ velocidad: Velocidad
24
+ color: ColorMarquee
25
+ }
26
+
27
+ const marqueeSchema: z.ZodType<MarqueeProps> = z.object({
28
+ mensajes: z.array(z.object({ texto: z.string().min(1) })),
29
+ velocidad: z.enum(VELOCIDADES),
30
+ color: z.enum(COLORES_MARQUEE),
31
+ })
32
+
33
+ const marqueeDefaults: MarqueeProps = {
34
+ mensajes: [
35
+ { texto: "Envío gratis desde $150.000" },
36
+ { texto: "Cambios y devoluciones en 30 días" },
37
+ { texto: "Paga con PSE, tarjeta o efectivo" },
38
+ { texto: "Nuevos drops cada semana" },
39
+ ],
40
+ velocidad: "media",
41
+ color: "acento",
42
+ }
43
+
44
+ const DURACION: Record<Velocidad, string> = {
45
+ lenta: "42s",
46
+ media: "28s",
47
+ rapida: "16s",
48
+ }
49
+
50
+ function estiloBanda(color: ColorMarquee): { background: string; color: string } {
51
+ switch (color) {
52
+ case "tinta":
53
+ return { background: "var(--cms-texto,#151515)", color: "var(--cms-fondo,#fafaf8)" }
54
+ case "degradado":
55
+ return {
56
+ background: "var(--cms-degradado,linear-gradient(120deg,#3b2bff,#ff3b2e))",
57
+ color: "var(--cms-primario-texto,#ffffff)",
58
+ }
59
+ case "acento":
60
+ default:
61
+ return { background: "var(--cms-acento,#3b2bff)", color: "var(--cms-primario-texto,#ffffff)" }
62
+ }
63
+ }
64
+
65
+ export const marqueeBlock: BlockDefinition<MarqueeProps, RenderContext> = {
66
+ label: "Banda en movimiento",
67
+ schema: marqueeSchema,
68
+ defaults: marqueeDefaults,
69
+ Component: ({ props }) => {
70
+ const banda = estiloBanda(props.color)
71
+ const rawId = useId()
72
+ // `useId` genera `:r0:`; los dos puntos no son válidos en un nombre de animación.
73
+ const anim = `cms-mq-${rawId.replace(/:/g, "")}`
74
+ const mensajes = props.mensajes.length ? props.mensajes : marqueeDefaults.mensajes
75
+
76
+ // Una "tira" con todos los mensajes; se duplica para un loop sin costuras.
77
+ const Tira = ({ ariaHidden }: { ariaHidden?: boolean }) => (
78
+ <div className="flex shrink-0 items-center" aria-hidden={ariaHidden}>
79
+ {mensajes.map((mensaje, i) => (
80
+ <span key={i} className="flex items-center whitespace-nowrap">
81
+ <span
82
+ className="px-6 text-[13px] font-semibold uppercase tracking-[0.1em]"
83
+ style={{ fontFamily: "var(--cms-fuente-texto)" }}
84
+ >
85
+ {mensaje.texto}
86
+ </span>
87
+ <span aria-hidden className="opacity-60">
88
+
89
+ </span>
90
+ </span>
91
+ ))}
92
+ </div>
93
+ )
94
+
95
+ const css =
96
+ `@keyframes ${anim}{from{transform:translateX(0)}to{transform:translateX(-50%)}}` +
97
+ `.${anim}-track{display:flex;width:max-content;animation:${anim} ${DURACION[props.velocidad]} linear infinite}` +
98
+ `@media (prefers-reduced-motion: reduce){.${anim}-track{animation:none;justify-content:center;width:100%}}`
99
+
100
+ return (
101
+ <section
102
+ aria-label="Anuncios de la tienda"
103
+ className="w-full overflow-hidden py-3"
104
+ style={{ ...banda, borderRadius: "var(--cms-radio)" }}
105
+ >
106
+ <style dangerouslySetInnerHTML={{ __html: css }} />
107
+ <div className={`${anim}-track`}>
108
+ <Tira />
109
+ <Tira ariaHidden />
110
+ </div>
111
+ </section>
112
+ )
113
+ },
114
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { newsletterBlock } from "./newsletter"
4
+ import { mockCtx } from "../test/mock-ctx"
5
+
6
+ const fondos = ["fondo", "acento"] as const
7
+
8
+ describe("newsletterBlock", () => {
9
+ it.each(fondos)("renderiza sin crash con fondo %s", (fondo) => {
10
+ const props = { ...newsletterBlock.defaults, fondo }
11
+ expect(newsletterBlock.schema.safeParse(props).success).toBe(true)
12
+
13
+ const html = renderToStaticMarkup(<newsletterBlock.Component props={props} ctx={mockCtx()} />)
14
+
15
+ expect(html).toContain(newsletterBlock.defaults.titulo)
16
+ expect(html).toContain(newsletterBlock.defaults.texto)
17
+ expect(html).toContain(newsletterBlock.defaults.textoBoton)
18
+ expect(html).toContain(newsletterBlock.defaults.notaLegal)
19
+ expect(html).toContain(`placeholder="${newsletterBlock.defaults.placeholder}"`)
20
+ })
21
+
22
+ it("titulo es un <h2> (no <h1>) y el input tiene <label> asociado", () => {
23
+ const html = renderToStaticMarkup(
24
+ <newsletterBlock.Component props={newsletterBlock.defaults} ctx={mockCtx()} />
25
+ )
26
+
27
+ expect(html).toContain("<h2")
28
+ expect(html).not.toContain("<h1")
29
+ expect(html).toContain("<label")
30
+ expect(html).toContain('type="email"')
31
+
32
+ const labelMatch = html.match(/<label[^>]*for="([^"]+)"/)
33
+ expect(labelMatch).not.toBeNull()
34
+ const inputMatch = html.match(/<input[^>]*id="([^"]+)"/)
35
+ expect(inputMatch).not.toBeNull()
36
+ expect(labelMatch?.[1]).toBe(inputMatch?.[1])
37
+ })
38
+
39
+ it("no usa <img> ni <a> directos: pasa siempre por ctx.componentes", () => {
40
+ const html = renderToStaticMarkup(
41
+ <newsletterBlock.Component props={newsletterBlock.defaults} ctx={mockCtx()} />
42
+ )
43
+
44
+ expect(html).not.toContain("<img ")
45
+ expect(html).not.toContain("<a ")
46
+ })
47
+
48
+ it("el <form> es presentacional: sin action ni onSubmit que rompa sin backend", () => {
49
+ const html = renderToStaticMarkup(
50
+ <newsletterBlock.Component props={newsletterBlock.defaults} ctx={mockCtx()} />
51
+ )
52
+
53
+ expect(html).toContain("<form")
54
+ expect(html).not.toContain('action="')
55
+ })
56
+
57
+ it("defaults pasan el schema", () => {
58
+ expect(newsletterBlock.schema.safeParse(newsletterBlock.defaults).success).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,105 @@
1
+ import { useId } from "react"
2
+ import { z } from "zod"
3
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
4
+ import type { RenderContext } from "../context"
5
+
6
+ export type NewsletterProps = {
7
+ fondo: "fondo" | "acento"
8
+ titulo: string
9
+ texto: string
10
+ placeholder: string
11
+ textoBoton: string
12
+ notaLegal: string
13
+ }
14
+
15
+ const newsletterSchema: z.ZodType<NewsletterProps> = z.object({
16
+ fondo: z.enum(["fondo", "acento"]),
17
+ titulo: z.string().min(1),
18
+ texto: z.string(),
19
+ placeholder: z.string(),
20
+ textoBoton: z.string().min(1),
21
+ notaLegal: z.string(),
22
+ })
23
+
24
+ const newsletterDefaults: NewsletterProps = {
25
+ fondo: "fondo",
26
+ titulo: "Entérate primero de cada drop",
27
+ texto: "10% de descuento en tu primera compra al suscribirte.",
28
+ placeholder: "Tu correo",
29
+ textoBoton: "Suscribirme",
30
+ notaLegal: "Al suscribirte aceptás recibir novedades por correo. Podés darte de baja cuando quieras.",
31
+ }
32
+
33
+ export const newsletterBlock: BlockDefinition<NewsletterProps, RenderContext> = {
34
+ label: "Newsletter",
35
+ schema: newsletterSchema,
36
+ defaults: newsletterDefaults,
37
+ Component: ({ props }) => {
38
+ const sobreAcento = props.fondo === "acento"
39
+ const emailId = useId()
40
+ return (
41
+ <section
42
+ className="w-full px-5 py-10 text-center md:px-8 md:py-14 lg:py-16"
43
+ style={{
44
+ background: sobreAcento ? "var(--cms-acento)" : "var(--cms-fondo)",
45
+ color: sobreAcento ? "var(--cms-primario-texto)" : "var(--cms-texto)",
46
+ borderRadius: "var(--cms-radio)",
47
+ }}
48
+ >
49
+ <h2
50
+ className="m-0 text-2xl leading-tight md:text-[28px]"
51
+ style={{
52
+ fontFamily: "var(--cms-fuente-titulo)",
53
+ fontWeight: "var(--cms-titulo-peso)",
54
+ textTransform: "var(--cms-titulo-transform)",
55
+ letterSpacing: "var(--cms-titulo-espaciado)",
56
+ }}
57
+ >
58
+ {props.titulo}
59
+ </h2>
60
+ <p
61
+ className="mx-auto mt-2.5 max-w-md text-[13px]"
62
+ style={{ color: sobreAcento ? "var(--cms-primario-texto)" : "var(--cms-texto-suave)", opacity: sobreAcento ? 0.85 : 1 }}
63
+ >
64
+ {props.texto}
65
+ </p>
66
+ <form className="mx-auto mt-5 flex w-full max-w-sm flex-col items-stretch gap-2.5 md:max-w-none md:flex-row md:flex-wrap md:items-center md:justify-center">
67
+ <label htmlFor={emailId} className="sr-only">
68
+ {props.placeholder}
69
+ </label>
70
+ <input
71
+ id={emailId}
72
+ type="email"
73
+ name="email"
74
+ placeholder={props.placeholder}
75
+ className="h-11 w-full px-4 text-[13px] md:w-80"
76
+ style={{
77
+ border: "var(--cms-grosor-borde) solid var(--cms-borde)",
78
+ borderRadius: "var(--cms-radio)",
79
+ background: "var(--cms-superficie)",
80
+ color: "var(--cms-texto)",
81
+ }}
82
+ />
83
+ <button
84
+ type="submit"
85
+ className="inline-block h-11 w-full px-6 text-xs font-semibold uppercase tracking-[0.08em] md:w-auto"
86
+ style={{
87
+ background: "var(--cms-primario)",
88
+ color: "var(--cms-primario-texto)",
89
+ borderRadius: "var(--cms-radio)",
90
+ fontFamily: "var(--cms-fuente-texto)",
91
+ }}
92
+ >
93
+ {props.textoBoton}
94
+ </button>
95
+ </form>
96
+ <p
97
+ className="mx-auto mt-3 max-w-md text-[11px]"
98
+ style={{ color: sobreAcento ? "var(--cms-primario-texto)" : "var(--cms-texto-suave)", opacity: sobreAcento ? 0.7 : 1 }}
99
+ >
100
+ {props.notaLegal}
101
+ </p>
102
+ </section>
103
+ )
104
+ },
105
+ }
@@ -0,0 +1,160 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { fileURLToPath } from "node:url"
3
+ import { describe, expect, it, vi } from "vitest"
4
+ import { renderToStaticMarkup } from "react-dom/server"
5
+ import type { HttpTypes } from "@medusajs/types"
6
+ import { productGridBlock, agregarProducto } from "./product-grid"
7
+ import { mockCtx } from "../test/mock-ctx"
8
+
9
+ const codigoFuente = readFileSync(fileURLToPath(new URL("./product-grid.tsx", import.meta.url)), "utf-8")
10
+
11
+ const productos = [
12
+ {
13
+ id: "prod_1",
14
+ title: "Sneaker Runner Blanco",
15
+ handle: "sneaker-runner-blanco",
16
+ collection_id: "col_1",
17
+ thumbnail: null,
18
+ variants: [
19
+ {
20
+ id: "var_1",
21
+ title: "M",
22
+ calculated_price: { calculated_amount: 289900, currency_code: "cop" },
23
+ inventory_quantity: 5,
24
+ allow_backorder: false,
25
+ },
26
+ {
27
+ id: "var_2",
28
+ title: "L",
29
+ calculated_price: { calculated_amount: 289900, currency_code: "cop" },
30
+ inventory_quantity: 0,
31
+ allow_backorder: false,
32
+ },
33
+ ],
34
+ },
35
+ {
36
+ id: "prod_2",
37
+ title: "Hoodie Heavy Negro",
38
+ handle: "hoodie-heavy-negro",
39
+ collection_id: "col_2",
40
+ thumbnail: null,
41
+ variants: [
42
+ {
43
+ id: "var_3",
44
+ title: "M",
45
+ calculated_price: { calculated_amount: 179900, currency_code: "cop" },
46
+ inventory_quantity: 5,
47
+ allow_backorder: false,
48
+ },
49
+ ],
50
+ },
51
+ ] as unknown as HttpTypes.StoreProduct[]
52
+
53
+ describe("productGridBlock", () => {
54
+ it("defaults pasan el schema", () => {
55
+ expect(productGridBlock.schema.safeParse(productGridBlock.defaults).success).toBe(true)
56
+ })
57
+
58
+ it("renderiza el titulo de seccion y una tarjeta por producto de ctx.products", () => {
59
+ const html = renderToStaticMarkup(
60
+ <productGridBlock.Component props={productGridBlock.defaults} ctx={mockCtx({ products: productos })} />
61
+ )
62
+
63
+ expect(html).toContain(productGridBlock.defaults.titulo)
64
+ for (const producto of productos) {
65
+ expect(html).toContain(`href="/products/${producto.handle}"`)
66
+ expect(html).toContain(producto.title)
67
+ }
68
+ })
69
+
70
+ it.each(["minimal", "quick-add", "tallas", "quick-view"] as const)(
71
+ "renderiza sin crash con card: %s",
72
+ (card) => {
73
+ const props = { ...productGridBlock.defaults, card }
74
+ expect(productGridBlock.schema.safeParse(props).success).toBe(true)
75
+
76
+ const html = renderToStaticMarkup(
77
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
78
+ )
79
+
80
+ expect(html).toContain(productos[0]!.title)
81
+ }
82
+ )
83
+
84
+ it('card "quick-add" muestra un boton para agregar', () => {
85
+ const props = { ...productGridBlock.defaults, card: "quick-add" as const }
86
+ const html = renderToStaticMarkup(
87
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
88
+ )
89
+
90
+ expect(html).toContain("Añadir")
91
+ })
92
+
93
+ it('card "tallas" tacha las tallas agotadas', () => {
94
+ const props = { ...productGridBlock.defaults, card: "tallas" as const }
95
+ const html = renderToStaticMarkup(
96
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
97
+ )
98
+
99
+ expect(html).toContain("line-through")
100
+ })
101
+
102
+ it('card "quick-view" muestra un boton de vista rapida', () => {
103
+ const props = { ...productGridBlock.defaults, card: "quick-view" as const }
104
+ const html = renderToStaticMarkup(
105
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
106
+ )
107
+
108
+ expect(html).toContain("Vista rápida")
109
+ })
110
+
111
+ it("coleccionId filtra ctx.products por collection_id", () => {
112
+ const props = { ...productGridBlock.defaults, coleccionId: "col_1" }
113
+ const html = renderToStaticMarkup(
114
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
115
+ )
116
+
117
+ expect(html).toContain("Sneaker Runner Blanco")
118
+ expect(html).not.toContain("Hoodie Heavy Negro")
119
+ })
120
+
121
+ it("coleccionId vacío no filtra: aparecen todos los productos", () => {
122
+ const props = { ...productGridBlock.defaults, coleccionId: "" }
123
+ const html = renderToStaticMarkup(
124
+ <productGridBlock.Component props={props} ctx={mockCtx({ products: productos })} />
125
+ )
126
+
127
+ expect(html).toContain("Sneaker Runner Blanco")
128
+ expect(html).toContain("Hoodie Heavy Negro")
129
+ })
130
+
131
+ it("con thumbnail/variant sin imagen usa un fallback en vez de <img src=\"\">", () => {
132
+ const html = renderToStaticMarkup(
133
+ <productGridBlock.Component props={productGridBlock.defaults} ctx={mockCtx({ products: productos })} />
134
+ )
135
+
136
+ expect(html).not.toContain('src=""')
137
+ })
138
+
139
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
140
+ expect(codigoFuente).not.toMatch(/<img[\s>]/)
141
+ expect(codigoFuente).not.toMatch(/<a[\s>]/)
142
+ })
143
+ })
144
+
145
+ describe("agregarProducto", () => {
146
+ it("llama ctx.acciones.agregarAlCarrito con el variantId", async () => {
147
+ const spy = vi.fn(async () => ({ ok: true }))
148
+ const ctx = mockCtx({ acciones: { agregarAlCarrito: spy } })
149
+
150
+ await agregarProducto(ctx, "var_1")
151
+
152
+ expect(spy).toHaveBeenCalledWith("var_1")
153
+ })
154
+
155
+ it("devuelve la promesa de agregarAlCarrito", async () => {
156
+ const ctx = mockCtx({ acciones: { agregarAlCarrito: vi.fn(async () => ({ ok: true })) } })
157
+
158
+ await expect(agregarProducto(ctx, "var_1")).resolves.toEqual({ ok: true })
159
+ })
160
+ })
@@ -0,0 +1,76 @@
1
+ "use client"
2
+
3
+ import { z } from "zod"
4
+ import type { BlockDefinition } from "@arroyavecommerce/cms-core"
5
+ import type { RenderContext } from "../context"
6
+ import { TarjetaProducto } from "../catalog/product-card"
7
+
8
+ export { agregarProducto } from "../catalog/product-card"
9
+
10
+ export type ProductGridProps = {
11
+ titulo: string
12
+ coleccionId: string
13
+ columnas: 3 | 4
14
+ card: "minimal" | "quick-add" | "tallas" | "quick-view"
15
+ }
16
+
17
+ const productGridSchema: z.ZodType<ProductGridProps> = z.object({
18
+ titulo: z.string().min(1),
19
+ coleccionId: z.string(),
20
+ columnas: z.union([z.literal(3), z.literal(4)]),
21
+ card: z.enum(["minimal", "quick-add", "tallas", "quick-view"]),
22
+ })
23
+
24
+ const productGridDefaults: ProductGridProps = {
25
+ titulo: "Novedades",
26
+ coleccionId: "",
27
+ columnas: 4,
28
+ card: "quick-add",
29
+ }
30
+
31
+ export const productGridBlock: BlockDefinition<ProductGridProps, RenderContext> = {
32
+ label: "Grilla de productos",
33
+ schema: productGridSchema,
34
+ defaults: productGridDefaults,
35
+ Component: ({ props, ctx }) => {
36
+ const productos = props.coleccionId
37
+ ? ctx.products.filter((product) => product.collection_id === props.coleccionId)
38
+ : ctx.products
39
+
40
+ return (
41
+ <div
42
+ style={{
43
+ background: "var(--cms-fondo)",
44
+ color: "var(--cms-texto)",
45
+ border: "var(--cms-grosor-borde) solid var(--cms-borde)",
46
+ borderRadius: "var(--cms-radio)",
47
+ }}
48
+ className="w-full p-7"
49
+ >
50
+ <div className="flex items-baseline justify-between">
51
+ <h2
52
+ className="m-0"
53
+ style={{
54
+ fontFamily: "var(--cms-fuente-titulo)",
55
+ fontWeight: "var(--cms-titulo-peso)",
56
+ textTransform: "var(--cms-titulo-transform)",
57
+ letterSpacing: "var(--cms-titulo-espaciado)",
58
+ fontSize: "26px",
59
+ }}
60
+ >
61
+ {props.titulo}
62
+ </h2>
63
+ </div>
64
+ <div
65
+ className={`mt-5 grid w-full grid-cols-2 gap-5 ${
66
+ props.columnas === 3 ? "md:grid-cols-3" : "md:grid-cols-4"
67
+ }`}
68
+ >
69
+ {productos.map((product) => (
70
+ <TarjetaProducto key={product.id} product={product} card={props.card} ctx={ctx} />
71
+ ))}
72
+ </div>
73
+ </div>
74
+ )
75
+ },
76
+ }