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