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