@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,225 @@
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 { BuyBox, agregarDesdeBuyBox } from "./buy-box"
6
+ import { mockCtx, fixtureDetalle } from "../test/mock-ctx"
7
+
8
+ const codigoFuente = readFileSync(fileURLToPath(new URL("./buy-box.tsx", import.meta.url)), "utf-8")
9
+
10
+ describe("BuyBox", () => {
11
+ it("renderiza un solo h1 con el titulo del producto", () => {
12
+ const detalle = fixtureDetalle()
13
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
14
+
15
+ const matches = html.match(/<h1[\s>]/g) ?? []
16
+ expect(matches).toHaveLength(1)
17
+ expect(html).toContain(detalle.producto.titulo)
18
+ })
19
+
20
+ it("renderiza precio, compareAt tachado y descuentoPct", () => {
21
+ const detalle = fixtureDetalle()
22
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
23
+
24
+ expect(html).toContain(detalle.producto.precio)
25
+ expect(html).toContain(detalle.producto.compareAt!)
26
+ expect(html).toContain("-17%")
27
+ })
28
+
29
+ it("renderiza el rating cuando esta presente", () => {
30
+ const detalle = fixtureDetalle()
31
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
32
+
33
+ expect(html).toContain("4.6")
34
+ })
35
+
36
+ it("renderiza swatches de color", () => {
37
+ const detalle = fixtureDetalle()
38
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
39
+
40
+ for (const color of detalle.variantes.color ?? []) {
41
+ expect(html).toContain(color.etiqueta)
42
+ }
43
+ })
44
+
45
+ it("renderiza chips de talla, una agotada tachada, y aviso de pocas unidades", () => {
46
+ const detalle = fixtureDetalle()
47
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
48
+
49
+ const agotada = (detalle.variantes.talla ?? []).find((t) => t.agotada)
50
+ expect(agotada).toBeDefined()
51
+ expect(html).toContain("Solo quedan 2")
52
+ expect(html).toContain("line-through")
53
+ })
54
+
55
+ it("agregarDesdeBuyBox llama a ctx.acciones.agregarAlCarrito con el variantId resuelto", async () => {
56
+ const detalle = fixtureDetalle()
57
+ const agregarAlCarrito = vi.fn(async () => ({ ok: true }))
58
+ const ctx = mockCtx({ acciones: { agregarAlCarrito } })
59
+
60
+ await agregarDesdeBuyBox(ctx, detalle, { color: "blanco", talla: "39" })
61
+
62
+ expect(agregarAlCarrito).toHaveBeenCalledWith(detalle.resolverVariante({ color: "blanco", talla: "39" }))
63
+ })
64
+
65
+ it("agregarDesdeBuyBox no llama la accion si no hay variante resuelta", async () => {
66
+ const detalle = fixtureDetalle()
67
+ const agregarAlCarrito = vi.fn(async () => ({ ok: true }))
68
+ const ctx = mockCtx({ acciones: { agregarAlCarrito } })
69
+
70
+ await agregarDesdeBuyBox(ctx, detalle, {})
71
+
72
+ expect(agregarAlCarrito).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it("sin talla seleccionada, el CTA de agregar esta deshabilitado", () => {
76
+ const detalle = fixtureDetalle()
77
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
78
+
79
+ expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Agregar a la bolsa<\/button>/)
80
+ })
81
+
82
+ it("mostrarFavoritos false no renderiza el boton de favoritos", () => {
83
+ const detalle = fixtureDetalle()
84
+ const html = renderToStaticMarkup(
85
+ <BuyBox detalle={detalle} ctx={mockCtx()} mostrarFavoritos={false} />
86
+ )
87
+
88
+ expect(html).not.toContain("♡")
89
+ expect(html).not.toContain("favoritos")
90
+ })
91
+
92
+ it("mostrarFavoritos true renderiza el boton de favoritos", () => {
93
+ const detalle = fixtureDetalle()
94
+ const html = renderToStaticMarkup(
95
+ <BuyBox detalle={detalle} ctx={mockCtx()} mostrarFavoritos={true} />
96
+ )
97
+
98
+ expect(html).toContain("♡")
99
+ })
100
+
101
+ it('compraRapida true renderiza el boton "Comprar ahora"', () => {
102
+ const detalle = fixtureDetalle()
103
+ const html = renderToStaticMarkup(
104
+ <BuyBox detalle={detalle} ctx={mockCtx()} compraRapida={true} />
105
+ )
106
+
107
+ expect(html).toContain("Comprar ahora")
108
+ })
109
+
110
+ it('compraRapida false no renderiza "Comprar ahora"', () => {
111
+ const detalle = fixtureDetalle()
112
+ const html = renderToStaticMarkup(
113
+ <BuyBox detalle={detalle} ctx={mockCtx()} compraRapida={false} />
114
+ )
115
+
116
+ expect(html).not.toContain("Comprar ahora")
117
+ })
118
+
119
+ it("renderiza la card de envio con las lineas de envioInfo", () => {
120
+ const detalle = fixtureDetalle()
121
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
122
+
123
+ for (const linea of detalle.producto.envioInfo ?? []) {
124
+ expect(html).toContain(linea)
125
+ }
126
+ })
127
+
128
+ it("renderiza el acordeon con los titulos de cada seccion", () => {
129
+ const detalle = fixtureDetalle()
130
+ const html = renderToStaticMarkup(<BuyBox detalle={detalle} ctx={mockCtx()} />)
131
+
132
+ for (const seccion of detalle.producto.acordeon ?? []) {
133
+ expect(html).toContain(seccion.titulo)
134
+ }
135
+ })
136
+
137
+ it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
138
+ expect(codigoFuente).not.toMatch(/<img[\s>]/)
139
+ expect(codigoFuente).not.toMatch(/<a[\s>]/)
140
+ })
141
+ })
142
+
143
+ /**
144
+ * Regresion 4d-iii: BuyBox debe soportar modo "controlado opcional" (seleccion
145
+ * izada por PdpBody) sin romper el uso standalone (todos los tests de arriba,
146
+ * que NO pasan `seleccion`/`onSeleccionChange`, siguen usando el useState
147
+ * interno como fallback).
148
+ */
149
+ describe("BuyBox: modo controlado (seleccion izada)", () => {
150
+ it("con `seleccion` controlada, refleja color y talla seleccionados en el markup (aria-pressed)", () => {
151
+ const detalle = fixtureDetalle()
152
+ const seleccion = { color: "negro", talla: "38" }
153
+ const html = renderToStaticMarkup(
154
+ <BuyBox detalle={detalle} ctx={mockCtx()} seleccion={seleccion} onSeleccionChange={() => {}} />
155
+ )
156
+
157
+ // el color "negro" queda con aria-pressed="true" (outline activo) y la talla "38" tambien
158
+ const bloqueColorNegro = html.slice(html.indexOf('aria-label="Negro"'), html.indexOf('aria-label="Negro"') + 80)
159
+ expect(bloqueColorNegro).toContain('aria-pressed="true"')
160
+ const idxTalla38 = html.indexOf(">38<")
161
+ const bloqueTalla38 = html.slice(Math.max(0, idxTalla38 - 300), idxTalla38)
162
+ expect(bloqueTalla38).toContain('aria-pressed="true"')
163
+ })
164
+
165
+ it("seleccionar una talla en modo controlado llama a onSeleccionChange con {...previo, talla}", () => {
166
+ const detalle = fixtureDetalle()
167
+ const seleccionPrevia = { color: "blanco" }
168
+ const onSeleccionChange = vi.fn()
169
+ let elementoBuyBox: ReturnType<typeof BuyBox> | null = null
170
+
171
+ // BuyBox usa useState internamente incluso en modo controlado (fallback no usado);
172
+ // invocarlo dentro del render de un componente anfitrion mantiene el dispatcher de
173
+ // hooks activo (equivalente a inlinear un hook propio), permitiendo inspeccionar el
174
+ // arbol de elementos devuelto sin depender de un DOM real (no disponible en este setup).
175
+ function Harness() {
176
+ elementoBuyBox = BuyBox({
177
+ detalle,
178
+ ctx: mockCtx(),
179
+ seleccion: seleccionPrevia,
180
+ onSeleccionChange,
181
+ })
182
+ return elementoBuyBox
183
+ }
184
+ renderToStaticMarkup(<Harness />)
185
+
186
+ const talla38 = (detalle.variantes.talla ?? []).find((t) => t.valor === "38")!
187
+ const boton = encontrarBotonPorTexto(elementoBuyBox, talla38.etiqueta)
188
+ expect(boton).not.toBeNull()
189
+
190
+ boton.props.onClick()
191
+
192
+ expect(onSeleccionChange).toHaveBeenCalledWith({ ...seleccionPrevia, talla: "38" })
193
+ })
194
+ })
195
+
196
+ /**
197
+ * Camina un arbol de elementos React (no HTML) buscando un <button> cuyos
198
+ * children sean exactamente `texto`. Los sub-componentes de funcion (p.ej.
199
+ * `SelectorTalla`) no estan expandidos en el arbol devuelto por `BuyBox()`
200
+ * (JSX no los inlinea): para llegar a sus botones internos se invocan como
201
+ * funciones puras (ninguno de estos sub-componentes usa hooks) y se
202
+ * continua caminando su salida.
203
+ */
204
+ function encontrarBotonPorTexto(nodo: unknown, texto: string): any {
205
+ if (nodo == null || typeof nodo !== "object") return null
206
+ if (Array.isArray(nodo)) {
207
+ for (const hijo of nodo) {
208
+ const encontrado = encontrarBotonPorTexto(hijo, texto)
209
+ if (encontrado) return encontrado
210
+ }
211
+ return null
212
+ }
213
+ const el = nodo as { type?: unknown; props?: Record<string, unknown> }
214
+ if (el.type === "button" && el.props?.children === texto) {
215
+ return el
216
+ }
217
+ if (typeof el.type === "function") {
218
+ const renderizado = (el.type as (props: unknown) => unknown)(el.props)
219
+ return encontrarBotonPorTexto(renderizado, texto)
220
+ }
221
+ if (el.props && "children" in el.props) {
222
+ return encontrarBotonPorTexto(el.props.children, texto)
223
+ }
224
+ return null
225
+ }
@@ -0,0 +1,353 @@
1
+ "use client"
2
+
3
+ import { useState, type CSSProperties } from "react"
4
+ import type { RenderContext } from "../context"
5
+
6
+ export type Detalle = NonNullable<NonNullable<RenderContext["catalogo"]>["detalle"]>
7
+ export type Seleccion = { color?: string; talla?: string }
8
+
9
+ /**
10
+ * Ejecuta la acción de "agregar al carrito" a partir de la selección de variante
11
+ * del buy box. Extraída como función pura para poder testearla sin simular
12
+ * eventos de click en el DOM (ver `agregarProducto` en product-card.tsx).
13
+ */
14
+ export async function agregarDesdeBuyBox(ctx: RenderContext, detalle: Detalle, sel: Seleccion) {
15
+ const variantId = detalle.resolverVariante(sel)
16
+ if (!variantId) return
17
+ return ctx.acciones.agregarAlCarrito(variantId)
18
+ }
19
+
20
+ function Rating({ rating }: { rating: NonNullable<Detalle["producto"]["rating"]> }) {
21
+ const llenas = Math.max(0, Math.min(5, Math.round(rating.promedio)))
22
+ const estrellas = "★★★★★".slice(0, llenas) + "☆☆☆☆☆".slice(0, 5 - llenas)
23
+ return (
24
+ <div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--cms-texto-suave)" }}>
25
+ <span style={{ color: "var(--cms-texto)" }}>{estrellas}</span>
26
+ <span>
27
+ {rating.promedio.toFixed(1)} · {rating.total} reseñas
28
+ </span>
29
+ </div>
30
+ )
31
+ }
32
+
33
+ function SelectorColor({
34
+ colores,
35
+ seleccionado,
36
+ onSeleccionar,
37
+ ctx,
38
+ }: {
39
+ colores: NonNullable<Detalle["variantes"]["color"]>
40
+ seleccionado: string | undefined
41
+ onSeleccionar: (valor: string) => void
42
+ ctx: RenderContext
43
+ }) {
44
+ const { Imagen } = ctx.componentes
45
+ const etiquetaActual = colores.find((c) => c.valor === seleccionado)?.etiqueta
46
+
47
+ return (
48
+ <div>
49
+ <div className="mb-2 text-[12px] font-semibold">
50
+ Color:{" "}
51
+ <span className="font-normal" style={{ color: "var(--cms-texto-suave)" }}>
52
+ {etiquetaActual}
53
+ </span>
54
+ </div>
55
+ <div className="flex gap-2.5">
56
+ {colores.map((color) => {
57
+ const activo = color.valor === seleccionado
58
+ return (
59
+ <button
60
+ key={color.valor}
61
+ type="button"
62
+ onClick={() => onSeleccionar(color.valor)}
63
+ aria-label={color.etiqueta}
64
+ aria-pressed={activo}
65
+ className="h-[52px] w-[52px] overflow-hidden p-0"
66
+ style={{
67
+ borderRadius: "var(--cms-radio)",
68
+ outline: activo ? "2px solid var(--cms-texto)" : "none",
69
+ outlineOffset: "2px",
70
+ border: "none",
71
+ background: "transparent",
72
+ }}
73
+ >
74
+ {color.swatchUrl ? (
75
+ <Imagen src={color.swatchUrl} alt={color.etiqueta} className="h-full w-full object-cover" />
76
+ ) : (
77
+ <span
78
+ role="img"
79
+ aria-label={color.etiqueta}
80
+ className="block h-full w-full"
81
+ style={{ background: color.hex ?? "var(--cms-superficie)" }}
82
+ />
83
+ )}
84
+ </button>
85
+ )
86
+ })}
87
+ </div>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ function SelectorTalla({
93
+ tallas,
94
+ seleccionada,
95
+ onSeleccionar,
96
+ }: {
97
+ tallas: NonNullable<Detalle["variantes"]["talla"]>
98
+ seleccionada: string | undefined
99
+ onSeleccionar: (valor: string) => void
100
+ }) {
101
+ const pocasUnidades = tallas.filter((t) => !t.agotada && t.pocasUnidades != null)
102
+
103
+ return (
104
+ <div>
105
+ <div className="mb-2 flex items-baseline justify-between">
106
+ <span className="text-[12px] font-semibold">Talla</span>
107
+ </div>
108
+ <div className="grid grid-cols-5 gap-2">
109
+ {tallas.map((talla) => {
110
+ const activaSel = talla.valor === seleccionada
111
+ return (
112
+ <button
113
+ key={talla.valor}
114
+ type="button"
115
+ disabled={talla.agotada}
116
+ onClick={() => onSeleccionar(talla.valor)}
117
+ aria-pressed={activaSel}
118
+ className="py-3 text-center text-[12.5px]"
119
+ style={{
120
+ border: `var(--cms-grosor-borde) solid ${activaSel ? "var(--cms-texto)" : "var(--cms-borde)"}`,
121
+ borderRadius: "var(--cms-radio)",
122
+ background: activaSel ? "var(--cms-primario)" : "transparent",
123
+ color: talla.agotada
124
+ ? "var(--cms-texto-suave)"
125
+ : activaSel
126
+ ? "var(--cms-primario-texto)"
127
+ : "var(--cms-texto)",
128
+ textDecoration: talla.agotada ? "line-through" : "none",
129
+ fontWeight: activaSel ? 600 : 400,
130
+ }}
131
+ >
132
+ {talla.etiqueta}
133
+ </button>
134
+ )
135
+ })}
136
+ </div>
137
+ {pocasUnidades.map((talla) => (
138
+ <div key={talla.valor} className="mt-1.5 text-[11.5px]" style={{ color: "var(--cms-acento)" }}>
139
+ Solo quedan {talla.pocasUnidades} en talla {talla.etiqueta}
140
+ </div>
141
+ ))}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ function CardEnvio({ lineas }: { lineas: string[] }) {
147
+ return (
148
+ <div
149
+ className="flex flex-col gap-2 p-3.5 text-[12px]"
150
+ style={{
151
+ background: "var(--cms-superficie)",
152
+ border: "var(--cms-grosor-borde) solid var(--cms-borde)",
153
+ borderRadius: "var(--cms-radio)",
154
+ }}
155
+ >
156
+ {lineas.map((linea, i) => (
157
+ <span key={i}>{linea}</span>
158
+ ))}
159
+ </div>
160
+ )
161
+ }
162
+
163
+ function SeccionAcordeon({ seccion }: { seccion: { titulo: string; cuerpo: string } }) {
164
+ const [abierto, setAbierto] = useState(false)
165
+
166
+ return (
167
+ <div style={{ borderBottom: "var(--cms-grosor-borde) solid var(--cms-borde)" }}>
168
+ <button
169
+ type="button"
170
+ onClick={() => setAbierto((v) => !v)}
171
+ aria-expanded={abierto}
172
+ className="flex w-full items-center justify-between py-3.5 text-[13px] font-semibold"
173
+ style={{ background: "transparent", border: "none", color: "var(--cms-texto)" }}
174
+ >
175
+ <span>{seccion.titulo}</span>
176
+ <span aria-hidden="true">{abierto ? "-" : "+"}</span>
177
+ </button>
178
+ {abierto ? (
179
+ <p className="m-0 pb-3.5 text-[12.5px] leading-[1.6]" style={{ color: "var(--cms-texto-suave)" }}>
180
+ {seccion.cuerpo}
181
+ </p>
182
+ ) : null}
183
+ </div>
184
+ )
185
+ }
186
+
187
+ export function BuyBox({
188
+ detalle,
189
+ ctx,
190
+ mostrarFavoritos = false,
191
+ compraRapida = false,
192
+ seleccion,
193
+ onSeleccionChange,
194
+ }: {
195
+ detalle: Detalle
196
+ ctx: RenderContext
197
+ mostrarFavoritos?: boolean
198
+ compraRapida?: boolean
199
+ /** Selección controlada { color?, talla? }. Si se omite, BuyBox mantiene su propio estado (uso standalone). */
200
+ seleccion?: Seleccion
201
+ /** Requerido junto a `seleccion` para operar en modo controlado. */
202
+ onSeleccionChange?: (sel: Seleccion) => void
203
+ }) {
204
+ const { producto, variantes } = detalle
205
+ const [colorInterno, setColorInterno] = useState<string | undefined>(variantes.color?.[0]?.valor)
206
+ const [tallaInterno, setTallaInterno] = useState<string | undefined>(undefined)
207
+ const [favorito, setFavorito] = useState(false)
208
+
209
+ const controlado = seleccion != null && onSeleccionChange != null
210
+ const color = controlado ? seleccion.color : colorInterno
211
+ const talla = controlado ? seleccion.talla : tallaInterno
212
+
213
+ const setColor = (valor: string) => {
214
+ if (controlado) onSeleccionChange({ ...seleccion, color: valor })
215
+ else setColorInterno(valor)
216
+ }
217
+ const setTalla = (valor: string) => {
218
+ if (controlado) onSeleccionChange({ ...seleccion, talla: valor })
219
+ else setTallaInterno(valor)
220
+ }
221
+
222
+ const variantId = detalle.resolverVariante({ color, talla })
223
+
224
+ return (
225
+ <div className="flex flex-col gap-4">
226
+ <div>
227
+ {producto.eyebrow ? (
228
+ <div
229
+ className="text-[10.5px] font-semibold"
230
+ style={{ letterSpacing: ".12em", color: "var(--cms-acento)", fontFamily: "var(--cms-fuente-mono)" }}
231
+ >
232
+ {producto.eyebrow}
233
+ </div>
234
+ ) : null}
235
+ <h1
236
+ className="mt-2 text-[30px] leading-[1.1]"
237
+ style={{
238
+ fontFamily: "var(--cms-fuente-titulo)",
239
+ fontWeight: "var(--cms-titulo-peso)",
240
+ textTransform: "var(--cms-titulo-transform)",
241
+ letterSpacing: "var(--cms-titulo-espaciado)",
242
+ }}
243
+ >
244
+ {producto.titulo}
245
+ </h1>
246
+ <div className="mt-2.5 flex items-center gap-2.5">
247
+ <span className="text-[20px] font-semibold" style={{ fontFamily: "var(--cms-fuente-mono)" }}>
248
+ {producto.precio}
249
+ </span>
250
+ {producto.compareAt ? (
251
+ <span
252
+ className="text-[13px] font-medium"
253
+ style={{
254
+ fontFamily: "var(--cms-fuente-mono)",
255
+ color: "var(--cms-texto-suave)",
256
+ textDecoration: "line-through",
257
+ }}
258
+ >
259
+ {producto.compareAt}
260
+ </span>
261
+ ) : null}
262
+ {producto.descuentoPct != null ? (
263
+ <span
264
+ className="px-2.5 py-1 text-[10px] font-semibold"
265
+ style={{
266
+ background: "var(--cms-acento)",
267
+ color: "var(--cms-primario-texto)",
268
+ borderRadius: "var(--cms-radio)",
269
+ fontFamily: "var(--cms-fuente-mono)",
270
+ }}
271
+ >
272
+ -{producto.descuentoPct}%
273
+ </span>
274
+ ) : null}
275
+ </div>
276
+ {producto.rating ? (
277
+ <div className="mt-2">
278
+ <Rating rating={producto.rating} />
279
+ </div>
280
+ ) : null}
281
+ </div>
282
+
283
+ {variantes.color?.length ? (
284
+ <SelectorColor colores={variantes.color} seleccionado={color} onSeleccionar={setColor} ctx={ctx} />
285
+ ) : null}
286
+
287
+ {variantes.talla?.length ? (
288
+ <SelectorTalla tallas={variantes.talla} seleccionada={talla} onSeleccionar={setTalla} />
289
+ ) : null}
290
+
291
+ <div className="flex gap-2.5">
292
+ <button
293
+ type="button"
294
+ disabled={!variantId}
295
+ onClick={() => void agregarDesdeBuyBox(ctx, detalle, { color, talla })}
296
+ className="flex-1 py-[15px] text-center text-[13px] font-semibold uppercase tracking-[0.06em]"
297
+ style={{
298
+ background: "var(--cms-primario)",
299
+ color: "var(--cms-primario-texto)",
300
+ borderRadius: "var(--cms-radio)",
301
+ fontFamily: "var(--cms-fuente-texto)",
302
+ opacity: variantId ? 1 : 0.5,
303
+ }}
304
+ >
305
+ Agregar a la bolsa
306
+ </button>
307
+ {mostrarFavoritos ? (
308
+ <button
309
+ type="button"
310
+ onClick={() => setFavorito((v) => !v)}
311
+ aria-pressed={favorito}
312
+ aria-label="favoritos"
313
+ className="flex w-[52px] items-center justify-center text-[18px]"
314
+ style={{
315
+ border: "var(--cms-grosor-borde) solid var(--cms-borde)",
316
+ borderRadius: "var(--cms-radio)",
317
+ background: "transparent",
318
+ color: "var(--cms-texto)",
319
+ }}
320
+ >
321
+ {favorito ? "♥" : "♡"}
322
+ </button>
323
+ ) : null}
324
+ </div>
325
+
326
+ {compraRapida ? (
327
+ <button
328
+ type="button"
329
+ className="py-[13px] text-center text-[12.5px] font-semibold uppercase tracking-[0.06em]"
330
+ style={{
331
+ border: "var(--cms-grosor-borde) solid var(--cms-texto)",
332
+ borderRadius: "var(--cms-radio)",
333
+ fontFamily: "var(--cms-fuente-texto)",
334
+ background: "transparent",
335
+ color: "var(--cms-texto)",
336
+ }}
337
+ >
338
+ Comprar ahora
339
+ </button>
340
+ ) : null}
341
+
342
+ {producto.envioInfo?.length ? <CardEnvio lineas={producto.envioInfo} /> : null}
343
+
344
+ {producto.acordeon?.length ? (
345
+ <div>
346
+ {producto.acordeon.map((seccion) => (
347
+ <SeccionAcordeon key={seccion.titulo} seccion={seccion} />
348
+ ))}
349
+ </div>
350
+ ) : null}
351
+ </div>
352
+ )
353
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { CmsPageConfig } from "@arroyavecommerce/cms-core"
3
+ import { resolverPlpConfig, resolverPdpConfig, plpSettings, pdpSettings } from "./config"
4
+
5
+ describe("resolverPlpConfig", () => {
6
+ it("null -> defaults", () => {
7
+ expect(resolverPlpConfig(null)).toEqual({
8
+ filtros: "sidebar",
9
+ columnas: 3,
10
+ card: "minimal",
11
+ paginacion: "numerada",
12
+ mostrarContador: true,
13
+ vista: "grilla",
14
+ })
15
+ })
16
+
17
+ it("resuelve selections validas", () => {
18
+ const config: CmsPageConfig = {
19
+ version: 1,
20
+ selections: {
21
+ "plp.filtros": "pills",
22
+ "plp.columnas": "4",
23
+ "plp.mostrarContador": "no",
24
+ },
25
+ }
26
+ const resuelto = resolverPlpConfig(config)
27
+ expect(resuelto.filtros).toBe("pills")
28
+ expect(resuelto.columnas).toBe(4)
29
+ expect(resuelto.mostrarContador).toBe(false)
30
+ })
31
+
32
+ it("plp.filtros invalido -> fallback sidebar", () => {
33
+ const config: CmsPageConfig = { version: 1, selections: { "plp.filtros": "xxx" } }
34
+ expect(resolverPlpConfig(config).filtros).toBe("sidebar")
35
+ })
36
+
37
+ it("plp.columnas invalido -> fallback 3", () => {
38
+ const config: CmsPageConfig = { version: 1, selections: { "plp.columnas": "5" } }
39
+ expect(resolverPlpConfig(config).columnas).toBe(3)
40
+ })
41
+
42
+ it("plp.vista:'lista' se resuelve; invalido -> fallback grilla", () => {
43
+ const lista: CmsPageConfig = { version: 1, selections: { "plp.vista": "lista" } }
44
+ expect(resolverPlpConfig(lista).vista).toBe("lista")
45
+ const invalido: CmsPageConfig = { version: 1, selections: { "plp.vista": "xxx" } }
46
+ expect(resolverPlpConfig(invalido).vista).toBe("grilla")
47
+ })
48
+ })
49
+
50
+ describe("resolverPdpConfig", () => {
51
+ it("null -> defaults", () => {
52
+ expect(resolverPdpConfig(null)).toEqual({
53
+ galeria: "grid",
54
+ stickyBar: true,
55
+ compraRapida: false,
56
+ mostrarFavoritos: true,
57
+ mostrarReseñas: false,
58
+ mostrarRelacionados: true,
59
+ })
60
+ })
61
+
62
+ it("pdp.mostrarReseñas:'si' -> true", () => {
63
+ const config: CmsPageConfig = { version: 1, selections: { "pdp.mostrarReseñas": "si" } }
64
+ expect(resolverPdpConfig(config).mostrarReseñas).toBe(true)
65
+ })
66
+
67
+ it("pdp.galeria invalido -> fallback grid", () => {
68
+ const config: CmsPageConfig = { version: 1, selections: { "pdp.galeria": "xxx" } }
69
+ expect(resolverPdpConfig(config).galeria).toBe("grid")
70
+ })
71
+
72
+ it("booleano invalido -> el default de esa clave (no simple 'true')", () => {
73
+ const config: CmsPageConfig = { version: 1, selections: { "pdp.stickyBar": "xxx", "pdp.compraRapida": "xxx" } }
74
+ const resuelto = resolverPdpConfig(config)
75
+ expect(resuelto.stickyBar).toBe(true)
76
+ expect(resuelto.compraRapida).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe("plpSettings / pdpSettings", () => {
81
+ it("plpSettings contiene las claves esperadas", () => {
82
+ const claves = plpSettings.map((s) => s.key)
83
+ expect(claves).toEqual([
84
+ "plp.filtros",
85
+ "plp.columnas",
86
+ "plp.card",
87
+ "plp.paginacion",
88
+ "plp.mostrarContador",
89
+ "plp.vista",
90
+ ])
91
+ })
92
+
93
+ it("pdpSettings contiene las claves esperadas", () => {
94
+ const claves = pdpSettings.map((s) => s.key)
95
+ expect(claves).toEqual([
96
+ "pdp.galeria",
97
+ "pdp.stickyBar",
98
+ "pdp.compraRapida",
99
+ "pdp.mostrarFavoritos",
100
+ "pdp.mostrarReseñas",
101
+ "pdp.mostrarRelacionados",
102
+ ])
103
+ })
104
+
105
+ it("cada setting tiene label y opciones no vacias", () => {
106
+ for (const setting of [...plpSettings, ...pdpSettings]) {
107
+ expect(setting.label.length).toBeGreaterThan(0)
108
+ expect(setting.opciones.length).toBeGreaterThan(0)
109
+ for (const opcion of setting.opciones) {
110
+ expect(opcion.value.length).toBeGreaterThan(0)
111
+ expect(opcion.label.length).toBeGreaterThan(0)
112
+ }
113
+ }
114
+ })
115
+ })