@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,221 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
5
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
6
|
+
import {
|
|
7
|
+
TarjetaProducto,
|
|
8
|
+
porcentajeDescuento,
|
|
9
|
+
estaAgotado,
|
|
10
|
+
esNuevo,
|
|
11
|
+
quickAddRequiereSeleccion,
|
|
12
|
+
} from "./product-card"
|
|
13
|
+
import { mockCtx } from "../test/mock-ctx"
|
|
14
|
+
|
|
15
|
+
const codigoFuente = readFileSync(fileURLToPath(new URL("./product-card.tsx", import.meta.url)), "utf-8")
|
|
16
|
+
|
|
17
|
+
const producto = {
|
|
18
|
+
id: "prod_1",
|
|
19
|
+
title: "Sneaker Runner Blanco",
|
|
20
|
+
handle: "sneaker-runner-blanco",
|
|
21
|
+
thumbnail: null,
|
|
22
|
+
variants: [
|
|
23
|
+
{
|
|
24
|
+
id: "var_1",
|
|
25
|
+
title: "M",
|
|
26
|
+
calculated_price: { calculated_amount: 289900, currency_code: "cop" },
|
|
27
|
+
inventory_quantity: 5,
|
|
28
|
+
allow_backorder: false,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
} as unknown as HttpTypes.StoreProduct
|
|
32
|
+
|
|
33
|
+
describe("TarjetaProducto", () => {
|
|
34
|
+
it("renderiza el titulo del producto y un enlace a /products/:handle", () => {
|
|
35
|
+
const html = renderToStaticMarkup(
|
|
36
|
+
<TarjetaProducto product={producto} card="minimal" ctx={mockCtx()} />
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(html).toContain(producto.title)
|
|
40
|
+
expect(html).toContain(`href="/products/${producto.handle}"`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('card "quick-add" muestra un boton de añadir', () => {
|
|
44
|
+
const html = renderToStaticMarkup(
|
|
45
|
+
<TarjetaProducto product={producto} card="quick-add" ctx={mockCtx()} />
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(html).toContain("Añadir")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("no emite <img> ni <a> directos: todo pasa por ctx.componentes", () => {
|
|
52
|
+
expect(codigoFuente).not.toMatch(/<img[\s>]/)
|
|
53
|
+
expect(codigoFuente).not.toMatch(/<a[\s>]/)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Producto con precio original > precio actual (descuento derivable del calculated_price). */
|
|
58
|
+
const productoConDescuento = {
|
|
59
|
+
id: "prod_desc",
|
|
60
|
+
title: "Chaqueta Rebajada",
|
|
61
|
+
handle: "chaqueta-rebajada",
|
|
62
|
+
thumbnail: null,
|
|
63
|
+
variants: [
|
|
64
|
+
{
|
|
65
|
+
id: "var_d",
|
|
66
|
+
title: "M",
|
|
67
|
+
calculated_price: { calculated_amount: 200000, original_amount: 250000, currency_code: "cop" },
|
|
68
|
+
inventory_quantity: 5,
|
|
69
|
+
allow_backorder: false,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
} as unknown as HttpTypes.StoreProduct
|
|
73
|
+
|
|
74
|
+
const productoAgotado = {
|
|
75
|
+
id: "prod_ago",
|
|
76
|
+
title: "Gorra Sold Out",
|
|
77
|
+
handle: "gorra-sold-out",
|
|
78
|
+
thumbnail: null,
|
|
79
|
+
variants: [
|
|
80
|
+
{ id: "va", title: "S", calculated_price: { calculated_amount: 50000, currency_code: "cop" }, inventory_quantity: 0, allow_backorder: false },
|
|
81
|
+
{ id: "vb", title: "M", calculated_price: { calculated_amount: 50000, currency_code: "cop" }, inventory_quantity: 0, allow_backorder: false },
|
|
82
|
+
],
|
|
83
|
+
} as unknown as HttpTypes.StoreProduct
|
|
84
|
+
|
|
85
|
+
const productoConDosImagenes = {
|
|
86
|
+
id: "prod_img",
|
|
87
|
+
title: "Vestido Doble Foto",
|
|
88
|
+
handle: "vestido-doble-foto",
|
|
89
|
+
thumbnail: "https://img.test/thumb.jpg",
|
|
90
|
+
images: [
|
|
91
|
+
{ id: "img_1", url: "https://img.test/frente.jpg" },
|
|
92
|
+
{ id: "img_2", url: "https://img.test/espalda.jpg" },
|
|
93
|
+
],
|
|
94
|
+
variants: [
|
|
95
|
+
{ id: "vx", title: "U", calculated_price: { calculated_amount: 99000, currency_code: "cop" }, inventory_quantity: 5, allow_backorder: false },
|
|
96
|
+
],
|
|
97
|
+
} as unknown as HttpTypes.StoreProduct
|
|
98
|
+
|
|
99
|
+
const productoMultiTalla = {
|
|
100
|
+
id: "prod_multi",
|
|
101
|
+
title: "Buzo Tallas",
|
|
102
|
+
handle: "buzo-tallas",
|
|
103
|
+
thumbnail: null,
|
|
104
|
+
variants: [
|
|
105
|
+
{ id: "t_s", title: "S", calculated_price: { calculated_amount: 120000, currency_code: "cop" }, inventory_quantity: 3, allow_backorder: false },
|
|
106
|
+
{ id: "t_m", title: "M", calculated_price: { calculated_amount: 120000, currency_code: "cop" }, inventory_quantity: 3, allow_backorder: false },
|
|
107
|
+
],
|
|
108
|
+
} as unknown as HttpTypes.StoreProduct
|
|
109
|
+
|
|
110
|
+
describe("badges de la tarjeta", () => {
|
|
111
|
+
it("porcentajeDescuento deriva -N% de original_amount vs calculated_amount", () => {
|
|
112
|
+
expect(porcentajeDescuento(productoConDescuento)).toBe(20)
|
|
113
|
+
expect(porcentajeDescuento(producto)).toBeNull() // sin original_amount
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("muestra el badge de descuento sobre la imagen", () => {
|
|
117
|
+
const html = renderToStaticMarkup(
|
|
118
|
+
<TarjetaProducto product={productoConDescuento} card="minimal" ctx={mockCtx()} />
|
|
119
|
+
)
|
|
120
|
+
expect(html).toContain("-20%")
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("oculta el badge de descuento cuando no hay precio original", () => {
|
|
124
|
+
const html = renderToStaticMarkup(<TarjetaProducto product={producto} card="minimal" ctx={mockCtx()} />)
|
|
125
|
+
expect(html).not.toContain("-20%")
|
|
126
|
+
expect(html).not.toContain("Agotado")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("estaAgotado es true solo si todas las variantes estan sin stock", () => {
|
|
130
|
+
expect(estaAgotado(productoAgotado)).toBe(true)
|
|
131
|
+
expect(estaAgotado(producto)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("manage_inventory:false (stock no rastreado) NO cuenta como agotado", () => {
|
|
135
|
+
const sinRastreo = {
|
|
136
|
+
...productoAgotado,
|
|
137
|
+
variants: (productoAgotado.variants ?? []).map((v) => ({
|
|
138
|
+
...v,
|
|
139
|
+
manage_inventory: false,
|
|
140
|
+
inventory_quantity: undefined,
|
|
141
|
+
})),
|
|
142
|
+
} as typeof productoAgotado
|
|
143
|
+
expect(estaAgotado(sinRastreo)).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("muestra el badge Agotado cuando todo el producto esta sin stock", () => {
|
|
147
|
+
const html = renderToStaticMarkup(<TarjetaProducto product={productoAgotado} card="minimal" ctx={mockCtx()} />)
|
|
148
|
+
expect(html).toContain("Agotado")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("esNuevo respeta la ventana de dias sobre created_at", () => {
|
|
152
|
+
const ahora = Date.parse("2026-07-12T00:00:00Z")
|
|
153
|
+
const reciente = { created_at: "2026-07-10T00:00:00Z" } as unknown as HttpTypes.StoreProduct
|
|
154
|
+
const viejo = { created_at: "2026-01-01T00:00:00Z" } as unknown as HttpTypes.StoreProduct
|
|
155
|
+
const sinFecha = { created_at: null } as unknown as HttpTypes.StoreProduct
|
|
156
|
+
expect(esNuevo(reciente, ahora)).toBe(true)
|
|
157
|
+
expect(esNuevo(viejo, ahora)).toBe(false)
|
|
158
|
+
expect(esNuevo(sinFecha, ahora)).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe("hover con segunda imagen", () => {
|
|
163
|
+
it("renderiza ambas imagenes cuando existe images[1] (swap en hover)", () => {
|
|
164
|
+
const html = renderToStaticMarkup(
|
|
165
|
+
<TarjetaProducto product={productoConDosImagenes} card="minimal" ctx={mockCtx()} />
|
|
166
|
+
)
|
|
167
|
+
expect(html).toContain("https://img.test/thumb.jpg")
|
|
168
|
+
expect(html).toContain("https://img.test/espalda.jpg")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("respeta prefers-reduced-motion (motion-reduce:transition-none) y usa group-hover para el swap", () => {
|
|
172
|
+
const html = renderToStaticMarkup(
|
|
173
|
+
<TarjetaProducto product={productoConDosImagenes} card="minimal" ctx={mockCtx()} />
|
|
174
|
+
)
|
|
175
|
+
expect(html).toContain("group-hover:opacity-100")
|
|
176
|
+
expect(html).toContain("motion-reduce:transition-none")
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("sin segunda imagen no renderiza overlay (comportamiento actual)", () => {
|
|
180
|
+
const html = renderToStaticMarkup(<TarjetaProducto product={producto} card="minimal" ctx={mockCtx()} />)
|
|
181
|
+
expect(html).not.toContain("group-hover:opacity-100")
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("vista lista", () => {
|
|
186
|
+
it('vista:"lista" renderiza fila horizontal (data-card=lista) con titulo, precio y enlace', () => {
|
|
187
|
+
const html = renderToStaticMarkup(
|
|
188
|
+
<TarjetaProducto product={producto} card="minimal" ctx={mockCtx()} vista="lista" />,
|
|
189
|
+
)
|
|
190
|
+
expect(html).toContain('data-card="lista"')
|
|
191
|
+
expect(html).toContain("flex-row")
|
|
192
|
+
expect(html).toContain(producto.title)
|
|
193
|
+
expect(html).toContain(`href="/products/${producto.handle}"`)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('vista:"lista" con card "quick-add" mantiene el boton Añadir en la fila', () => {
|
|
197
|
+
const html = renderToStaticMarkup(
|
|
198
|
+
<TarjetaProducto product={producto} card="quick-add" ctx={mockCtx()} vista="lista" />,
|
|
199
|
+
)
|
|
200
|
+
expect(html).toContain("Añadir")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('vista por defecto ("grilla") NO usa el marcador de fila', () => {
|
|
204
|
+
const html = renderToStaticMarkup(
|
|
205
|
+
<TarjetaProducto product={producto} card="minimal" ctx={mockCtx()} />,
|
|
206
|
+
)
|
|
207
|
+
expect(html).not.toContain('data-card="lista"')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe("quick-add respeta tallas", () => {
|
|
212
|
+
it("requiere seleccion cuando hay multiples variantes", () => {
|
|
213
|
+
expect(quickAddRequiereSeleccion(productoMultiTalla)).toBe(true)
|
|
214
|
+
expect(quickAddRequiereSeleccion(producto)).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("card quick-add con una sola variante sigue mostrando Añadir directo", () => {
|
|
218
|
+
const html = renderToStaticMarkup(<TarjetaProducto product={producto} card="quick-add" ctx={mockCtx()} />)
|
|
219
|
+
expect(html).toContain("Añadir")
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type MouseEvent } from "react"
|
|
4
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
5
|
+
import type { RenderContext } from "../context"
|
|
6
|
+
|
|
7
|
+
export type CardVariant = "minimal" | "quick-add" | "tallas" | "quick-view"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ejecuta la acción de "agregar al carrito" del contexto. Extraída como función
|
|
11
|
+
* pura para poder testearla sin simular eventos de click en el DOM.
|
|
12
|
+
*/
|
|
13
|
+
export function agregarProducto(ctx: RenderContext, variantId: string) {
|
|
14
|
+
return ctx.acciones.agregarAlCarrito(variantId)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function primeraVariante(product: HttpTypes.StoreProduct): HttpTypes.StoreProductVariant | undefined {
|
|
18
|
+
return product.variants?.[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatearPrecio(variant: HttpTypes.StoreProductVariant | undefined): string | null {
|
|
22
|
+
const monto = variant?.calculated_price?.calculated_amount
|
|
23
|
+
if (monto == null) return null
|
|
24
|
+
const moneda = variant?.calculated_price?.currency_code ?? "COP"
|
|
25
|
+
try {
|
|
26
|
+
return new Intl.NumberFormat("es-CO", {
|
|
27
|
+
style: "currency",
|
|
28
|
+
currency: moneda.toUpperCase(),
|
|
29
|
+
maximumFractionDigits: 0,
|
|
30
|
+
}).format(monto)
|
|
31
|
+
} catch {
|
|
32
|
+
return `$ ${monto}`
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tallaAgotada(variant: HttpTypes.StoreProductVariant): boolean {
|
|
37
|
+
// `manage_inventory: false` = Medusa no rastrea stock → siempre disponible.
|
|
38
|
+
if (variant.manage_inventory === false) return false
|
|
39
|
+
return (variant.inventory_quantity ?? 0) <= 0 && !variant.allow_backorder
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Porcentaje de descuento de la card, derivado del `calculated_price` de la
|
|
44
|
+
* primera variante (`original_amount` vs `calculated_amount`) — el MISMO dato
|
|
45
|
+
* que alimenta el `compareAt`/`descuentoPct` del PDP, pero calculado aquí a
|
|
46
|
+
* nivel de tarjeta. Devuelve `null` si no hay precio original, si el original
|
|
47
|
+
* no supera al actual, o si los montos no son numéricos.
|
|
48
|
+
*/
|
|
49
|
+
export function porcentajeDescuento(product: HttpTypes.StoreProduct): number | null {
|
|
50
|
+
const cp = primeraVariante(product)?.calculated_price
|
|
51
|
+
const actual = cp?.calculated_amount
|
|
52
|
+
const original = cp?.original_amount
|
|
53
|
+
if (typeof actual !== "number" || typeof original !== "number") return null
|
|
54
|
+
if (original <= actual) return null
|
|
55
|
+
const pct = Math.round((1 - actual / original) * 100)
|
|
56
|
+
return pct > 0 ? pct : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `true` si el producto tiene variantes y TODAS están sin stock (sin backorder). */
|
|
60
|
+
export function estaAgotado(product: HttpTypes.StoreProduct): boolean {
|
|
61
|
+
const variantes = product.variants ?? []
|
|
62
|
+
if (variantes.length === 0) return false
|
|
63
|
+
return variantes.every((v) => tallaAgotada(v))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `true` si el producto se creó dentro de la ventana reciente (por defecto 14
|
|
68
|
+
* días). `created_at` SÍ existe en el tipo de card (`StoreProduct.created_at`),
|
|
69
|
+
* pero solo se puebla si el adapter/host lo pide en el query del listado; si el
|
|
70
|
+
* dato no viene, el badge "Nuevo" simplemente no se muestra (fail-safe). `ahora`
|
|
71
|
+
* es inyectable para tests deterministas.
|
|
72
|
+
*/
|
|
73
|
+
export function esNuevo(
|
|
74
|
+
product: HttpTypes.StoreProduct,
|
|
75
|
+
ahora: number = Date.now(),
|
|
76
|
+
ventanaDias = 14,
|
|
77
|
+
): boolean {
|
|
78
|
+
const created = product.created_at
|
|
79
|
+
if (!created) return false
|
|
80
|
+
const t = Date.parse(created)
|
|
81
|
+
if (Number.isNaN(t)) return false
|
|
82
|
+
const edad = ahora - t
|
|
83
|
+
return edad >= 0 && edad <= ventanaDias * 24 * 60 * 60 * 1000
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `true` si el botón `quick-add` debe abrir el selector (vista rápida) en vez de
|
|
88
|
+
* agregar a ciegas: ocurre cuando el producto tiene más de una variante/talla.
|
|
89
|
+
*/
|
|
90
|
+
export function quickAddRequiereSeleccion(product: HttpTypes.StoreProduct): boolean {
|
|
91
|
+
return (product.variants?.length ?? 0) > 1
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Imagen del producto (thumbnail) con fallback a un panel de superficie cuando no hay src. */
|
|
95
|
+
function ImagenProducto({
|
|
96
|
+
src,
|
|
97
|
+
alt,
|
|
98
|
+
ctx,
|
|
99
|
+
className = "aspect-[3/4] w-full object-cover [border-radius:var(--cms-radio)]",
|
|
100
|
+
}: {
|
|
101
|
+
src: string | null | undefined
|
|
102
|
+
alt: string
|
|
103
|
+
ctx: RenderContext
|
|
104
|
+
className?: string
|
|
105
|
+
}) {
|
|
106
|
+
if (!src) {
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
role="img"
|
|
110
|
+
aria-label={alt}
|
|
111
|
+
className={className}
|
|
112
|
+
style={{ background: "var(--cms-superficie)", borderRadius: "var(--cms-radio)" }}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
const { Imagen } = ctx.componentes
|
|
117
|
+
return <Imagen src={src} alt={alt} className={className} />
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Badge({ children, bg, color }: { children: string; bg: string; color: string }) {
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
className="px-2 py-1 text-[10px] font-semibold uppercase leading-none tracking-[0.06em]"
|
|
124
|
+
style={{ background: bg, color, borderRadius: "var(--cms-radio)", fontFamily: "var(--cms-fuente-mono)" }}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</span>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Badges apilados en la esquina superior izquierda (no tapan el quick-add
|
|
133
|
+
* inferior ni el quick-view inferior-derecho). `pointer-events-none` para que
|
|
134
|
+
* el enlace de card que cubre toda la imagen siga siendo clickeable. Orden:
|
|
135
|
+
* descuento → nuevo → agotado.
|
|
136
|
+
*/
|
|
137
|
+
function BadgesProducto({ product }: { product: HttpTypes.StoreProduct }) {
|
|
138
|
+
const pct = porcentajeDescuento(product)
|
|
139
|
+
const nuevo = esNuevo(product)
|
|
140
|
+
const agotado = estaAgotado(product)
|
|
141
|
+
if (pct == null && !nuevo && !agotado) return null
|
|
142
|
+
return (
|
|
143
|
+
<div className="pointer-events-none absolute left-2.5 top-2.5 z-10 flex flex-col items-start gap-1.5">
|
|
144
|
+
{pct != null ? (
|
|
145
|
+
<Badge bg="var(--cms-acento-2, #ff3b2e)" color="var(--cms-primario-texto, #fff)">{`-${pct}%`}</Badge>
|
|
146
|
+
) : null}
|
|
147
|
+
{nuevo ? <Badge bg="var(--cms-texto, #111)" color="var(--cms-fondo, #fff)">Nuevo</Badge> : null}
|
|
148
|
+
{agotado ? (
|
|
149
|
+
<Badge bg="var(--cms-superficie, #f4f4f4)" color="var(--cms-texto-suave, #666)">Agotado</Badge>
|
|
150
|
+
) : null}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Precio({ variant }: { variant: HttpTypes.StoreProductVariant | undefined }) {
|
|
156
|
+
const precio = formatearPrecio(variant)
|
|
157
|
+
if (!precio) return null
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
className="text-[12.5px] font-medium"
|
|
161
|
+
style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto-suave)" }}
|
|
162
|
+
>
|
|
163
|
+
{precio}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function TituloProducto({ children }: { children: string }) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="text-[13px] font-medium" style={{ fontFamily: "var(--cms-fuente-texto)" }}>
|
|
171
|
+
{children}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ChipTalla({
|
|
177
|
+
variant,
|
|
178
|
+
onClick,
|
|
179
|
+
seleccionada,
|
|
180
|
+
}: {
|
|
181
|
+
variant: HttpTypes.StoreProductVariant
|
|
182
|
+
onClick?: () => void
|
|
183
|
+
seleccionada?: boolean
|
|
184
|
+
}) {
|
|
185
|
+
const agotada = tallaAgotada(variant)
|
|
186
|
+
return (
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={onClick}
|
|
190
|
+
disabled={agotada}
|
|
191
|
+
className="min-w-[34px] px-0 py-1.5 text-center text-[11px]"
|
|
192
|
+
style={{
|
|
193
|
+
border: `var(--cms-grosor-borde) solid ${seleccionada ? "var(--cms-texto)" : "var(--cms-borde)"}`,
|
|
194
|
+
borderRadius: "var(--cms-radio)",
|
|
195
|
+
fontWeight: seleccionada ? 600 : 400,
|
|
196
|
+
color: agotada ? "var(--cms-texto-suave)" : "var(--cms-texto)",
|
|
197
|
+
textDecoration: agotada ? "line-through" : "none",
|
|
198
|
+
background: "transparent",
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{variant.title}
|
|
202
|
+
</button>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function BotonAñadir({
|
|
207
|
+
onClick,
|
|
208
|
+
className,
|
|
209
|
+
}: {
|
|
210
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void
|
|
211
|
+
className?: string
|
|
212
|
+
}) {
|
|
213
|
+
return (
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={onClick}
|
|
217
|
+
className={`w-full py-2.5 text-center text-[11.5px] font-semibold uppercase tracking-[0.08em] ${className ?? ""}`}
|
|
218
|
+
style={{
|
|
219
|
+
background: "var(--cms-primario)",
|
|
220
|
+
color: "var(--cms-primario-texto)",
|
|
221
|
+
borderRadius: "var(--cms-radio)",
|
|
222
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
Añadir
|
|
226
|
+
</button>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ModalVistaRapida({
|
|
231
|
+
product,
|
|
232
|
+
ctx,
|
|
233
|
+
onCerrar,
|
|
234
|
+
}: {
|
|
235
|
+
product: HttpTypes.StoreProduct
|
|
236
|
+
ctx: RenderContext
|
|
237
|
+
onCerrar: () => void
|
|
238
|
+
}) {
|
|
239
|
+
const variantes = product.variants ?? []
|
|
240
|
+
const [variantId, setVariantId] = useState<string | undefined>(variantes[0]?.id)
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
role="dialog"
|
|
245
|
+
aria-modal="true"
|
|
246
|
+
aria-label={product.title}
|
|
247
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-6"
|
|
248
|
+
style={{ background: "rgba(0,0,0,.5)" }}
|
|
249
|
+
>
|
|
250
|
+
<div
|
|
251
|
+
className="flex w-full max-w-md flex-col gap-4 p-6"
|
|
252
|
+
style={{ background: "var(--cms-fondo)", color: "var(--cms-texto)", borderRadius: "var(--cms-radio)" }}
|
|
253
|
+
>
|
|
254
|
+
<div className="flex items-start justify-between gap-4">
|
|
255
|
+
<TituloProducto>{product.title}</TituloProducto>
|
|
256
|
+
<button type="button" onClick={onCerrar} aria-label="Cerrar">
|
|
257
|
+
×
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
<Precio variant={variantes.find((v) => v.id === variantId) ?? variantes[0]} />
|
|
261
|
+
<div className="flex flex-wrap gap-1.5">
|
|
262
|
+
{variantes.map((v) => (
|
|
263
|
+
<ChipTalla key={v.id} variant={v} seleccionada={v.id === variantId} onClick={() => setVariantId(v.id)} />
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
<BotonAñadir onClick={() => variantId && agregarProducto(ctx, variantId)} />
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Vista de la tarjeta: "grilla" (columna, default) o "lista" (fila horizontal). */
|
|
273
|
+
export type CardVista = "grilla" | "lista"
|
|
274
|
+
|
|
275
|
+
export function TarjetaProducto({
|
|
276
|
+
product,
|
|
277
|
+
card,
|
|
278
|
+
ctx,
|
|
279
|
+
vista = "grilla",
|
|
280
|
+
}: {
|
|
281
|
+
product: HttpTypes.StoreProduct
|
|
282
|
+
card: CardVariant
|
|
283
|
+
ctx: RenderContext
|
|
284
|
+
vista?: CardVista
|
|
285
|
+
}) {
|
|
286
|
+
const [vistaRapidaAbierta, setVistaRapidaAbierta] = useState(false)
|
|
287
|
+
const variante = primeraVariante(product)
|
|
288
|
+
const imagen = product.thumbnail ?? variante?.thumbnail ?? null
|
|
289
|
+
const segundaImagen = product.images?.[1]?.url ?? null
|
|
290
|
+
const requiereSeleccion = quickAddRequiereSeleccion(product)
|
|
291
|
+
const { Enlace } = ctx.componentes
|
|
292
|
+
|
|
293
|
+
if (vista === "lista") {
|
|
294
|
+
return (
|
|
295
|
+
<div data-card="lista" className="flex flex-row items-center gap-4">
|
|
296
|
+
<div className="relative w-24 flex-none md:w-28">
|
|
297
|
+
<ImagenProducto src={imagen} alt={product.title} ctx={ctx} />
|
|
298
|
+
<Enlace href={`/products/${product.handle}`} className="absolute inset-0 z-[1]">
|
|
299
|
+
<span className="sr-only">{product.title}</span>
|
|
300
|
+
</Enlace>
|
|
301
|
+
<BadgesProducto product={product} />
|
|
302
|
+
</div>
|
|
303
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
304
|
+
<Enlace href={`/products/${product.handle}`} className="[color:var(--cms-texto)]">
|
|
305
|
+
<TituloProducto>{product.title}</TituloProducto>
|
|
306
|
+
</Enlace>
|
|
307
|
+
<Precio variant={variante} />
|
|
308
|
+
{card === "tallas" ? (
|
|
309
|
+
<div className="flex flex-wrap gap-1.5">
|
|
310
|
+
{(product.variants ?? []).map((v) => (
|
|
311
|
+
<ChipTalla key={v.id} variant={v} />
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
) : null}
|
|
315
|
+
</div>
|
|
316
|
+
{card === "quick-add" || card === "quick-view" ? (
|
|
317
|
+
<div className="flex-none">
|
|
318
|
+
{card === "quick-add" ? (
|
|
319
|
+
<div className="w-32">
|
|
320
|
+
<BotonAñadir
|
|
321
|
+
onClick={() => {
|
|
322
|
+
if (requiereSeleccion) setVistaRapidaAbierta(true)
|
|
323
|
+
else if (variante) void agregarProducto(ctx, variante.id)
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
) : (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => setVistaRapidaAbierta(true)}
|
|
331
|
+
className="px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.06em]"
|
|
332
|
+
style={{
|
|
333
|
+
background: "var(--cms-superficie)",
|
|
334
|
+
color: "var(--cms-texto)",
|
|
335
|
+
border: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
336
|
+
borderRadius: "var(--cms-radio)",
|
|
337
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
Vista rápida
|
|
341
|
+
</button>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
{vistaRapidaAbierta ? (
|
|
346
|
+
<ModalVistaRapida product={product} ctx={ctx} onCerrar={() => setVistaRapidaAbierta(false)} />
|
|
347
|
+
) : null}
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<div className="flex flex-col gap-2">
|
|
354
|
+
<div className="group relative overflow-hidden [border-radius:var(--cms-radio)]">
|
|
355
|
+
<ImagenProducto src={imagen} alt={product.title} ctx={ctx} />
|
|
356
|
+
{segundaImagen ? (
|
|
357
|
+
// Wrapper absolute: ImagenCms fuerza `position:relative` en su div, que
|
|
358
|
+
// gana sobre un `absolute` pasado por className → la 2ª imagen quedaría
|
|
359
|
+
// en flujo (abajo). El contenedor absolute la superpone correctamente.
|
|
360
|
+
<div className="pointer-events-none absolute inset-0 z-[1] opacity-0 transition-opacity duration-300 group-hover:opacity-100 motion-reduce:transition-none">
|
|
361
|
+
<ImagenProducto src={segundaImagen} alt={product.title} ctx={ctx} className="h-full w-full object-cover" />
|
|
362
|
+
</div>
|
|
363
|
+
) : null}
|
|
364
|
+
<Enlace href={`/products/${product.handle}`} className="absolute inset-0 z-[2]">
|
|
365
|
+
<span className="sr-only">{product.title}</span>
|
|
366
|
+
</Enlace>
|
|
367
|
+
<BadgesProducto product={product} />
|
|
368
|
+
{card === "quick-add" ? (
|
|
369
|
+
<div className="absolute inset-x-2.5 bottom-2.5 z-10">
|
|
370
|
+
<BotonAñadir
|
|
371
|
+
onClick={() => {
|
|
372
|
+
if (requiereSeleccion) setVistaRapidaAbierta(true)
|
|
373
|
+
else if (variante) void agregarProducto(ctx, variante.id)
|
|
374
|
+
}}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
) : null}
|
|
378
|
+
{card === "quick-view" ? (
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
onClick={() => setVistaRapidaAbierta(true)}
|
|
382
|
+
className="absolute bottom-2.5 right-2.5 z-10 px-3.5 py-2 text-[11px] font-semibold uppercase tracking-[0.06em]"
|
|
383
|
+
style={{
|
|
384
|
+
background: "var(--cms-superficie)",
|
|
385
|
+
color: "var(--cms-texto)",
|
|
386
|
+
border: "var(--cms-grosor-borde) solid var(--cms-borde)",
|
|
387
|
+
borderRadius: "var(--cms-radio)",
|
|
388
|
+
fontFamily: "var(--cms-fuente-texto)",
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
Vista rápida
|
|
392
|
+
</button>
|
|
393
|
+
) : null}
|
|
394
|
+
</div>
|
|
395
|
+
<Enlace href={`/products/${product.handle}`} className="[color:var(--cms-texto)]">
|
|
396
|
+
<TituloProducto>{product.title}</TituloProducto>
|
|
397
|
+
</Enlace>
|
|
398
|
+
<Precio variant={variante} />
|
|
399
|
+
{card === "tallas" ? (
|
|
400
|
+
<div className="flex flex-wrap gap-1.5">
|
|
401
|
+
{(product.variants ?? []).map((v) => (
|
|
402
|
+
<ChipTalla key={v.id} variant={v} />
|
|
403
|
+
))}
|
|
404
|
+
</div>
|
|
405
|
+
) : null}
|
|
406
|
+
{vistaRapidaAbierta ? (
|
|
407
|
+
<ModalVistaRapida product={product} ctx={ctx} onCerrar={() => setVistaRapidaAbierta(false)} />
|
|
408
|
+
) : null}
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CatalogRegistry } from "@arroyavecommerce/cms-storefront"
|
|
2
|
+
import type { RenderContext } from "../context"
|
|
3
|
+
import { PlpLayout } from "./plp"
|
|
4
|
+
import { PdpLayout } from "./pdp"
|
|
5
|
+
import { resolverPlpConfig, resolverPdpConfig, plpSettings, pdpSettings } from "./config"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cablea PLP/PDP del template al contrato `Template.catalogo`: cada
|
|
9
|
+
* componente resuelve su propia config (`CmsPageConfig` -> `PlpConfig`/
|
|
10
|
+
* `PdpConfig`) internamente antes de delegar en `PlpLayout`/`PdpLayout`.
|
|
11
|
+
*/
|
|
12
|
+
export const catalogo: CatalogRegistry<RenderContext> = {
|
|
13
|
+
plp: ({ ctx, pageConfig }) => <PlpLayout ctx={ctx} config={resolverPlpConfig(pageConfig)} />,
|
|
14
|
+
pdp: ({ ctx, pageConfig }) => <PdpLayout ctx={ctx} config={resolverPdpConfig(pageConfig)} />,
|
|
15
|
+
pageConfigSettings: { plp: plpSettings, pdp: pdpSettings },
|
|
16
|
+
}
|