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