@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,320 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+ import type { CartData, CartLineData, RenderContext } from "../context"
5
+
6
+ /**
7
+ * Mini-cart drawer — panel lateral DERECHO + scrim, estética "Kinetic Street"
8
+ * (superficie/radio/sombra suaves, tokens `--cms-*` CON fallback). Lo abre el
9
+ * botón de la bolsa del header (Opción A: el carrito vive en el template,
10
+ * alimentado por `ctx.carrito`).
11
+ *
12
+ * A11y: `role="dialog"` + `aria-modal`, cierre con Esc y con click en el scrim,
13
+ * foco inicial en el botón de cerrar y foco atrapado (Tab cicla dentro del
14
+ * panel). El body queda con scroll bloqueado mientras está abierto. La
15
+ * animación de entrada (slide desde la derecha) respeta
16
+ * `prefers-reduced-motion`.
17
+ *
18
+ * Es VIEW-ONLY + CTAs: sin edición de cantidades inline (eso vive en `/cart`).
19
+ * Si `carrito` es `null` o no tiene items → estado vacío con enlace al catálogo.
20
+ */
21
+
22
+ const claseBorde = "[border-bottom:var(--cms-grosor-borde,1px)_solid_var(--cms-borde,#dbd6c9)]"
23
+
24
+ const SELECTOR_FOCO =
25
+ 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
26
+
27
+ /** Ruta del catálogo para el estado vacío ("Seguir comprando"). */
28
+ const URL_CATALOGO = "/catalogo"
29
+
30
+ // ── Formato de precio (misma convención que catalog/product-card.tsx) ─────────
31
+
32
+ function formatearMonto(monto: number, moneda: string): string {
33
+ try {
34
+ return new Intl.NumberFormat("es-CO", {
35
+ style: "currency",
36
+ currency: moneda.toUpperCase(),
37
+ maximumFractionDigits: 0,
38
+ }).format(monto)
39
+ } catch {
40
+ return `$ ${monto}`
41
+ }
42
+ }
43
+
44
+ // ── Estilos de entrada (respeta prefers-reduced-motion) ──────────────────────
45
+
46
+ function EstiloMiniCart() {
47
+ return (
48
+ <style
49
+ dangerouslySetInnerHTML={{
50
+ __html:
51
+ "@keyframes cms-minicart-in{from{transform:translateX(100%)}to{transform:translateX(0)}}" +
52
+ "@keyframes cms-minicart-scrim-in{from{opacity:0}to{opacity:1}}" +
53
+ ".cms-minicart-panel{animation:cms-minicart-in .28s cubic-bezier(.22,1,.36,1)}" +
54
+ ".cms-minicart-scrim{animation:cms-minicart-scrim-in .28s ease-out}" +
55
+ "@media (prefers-reduced-motion: reduce){.cms-minicart-panel,.cms-minicart-scrim{animation:none}}",
56
+ }}
57
+ />
58
+ )
59
+ }
60
+
61
+ // ── Icono de cierre ──────────────────────────────────────────────────────────
62
+
63
+ function IconoCerrar() {
64
+ return (
65
+ <svg
66
+ viewBox="0 0 24 24"
67
+ width="20"
68
+ height="20"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ strokeWidth="1.8"
72
+ strokeLinecap="round"
73
+ aria-hidden="true"
74
+ focusable="false"
75
+ >
76
+ <path d="M6 6l12 12M18 6L6 18" />
77
+ </svg>
78
+ )
79
+ }
80
+
81
+ // ── Thumbnail de línea (via ctx.componentes.Imagen, con fallback de superficie) ─
82
+
83
+ function Miniatura({
84
+ src,
85
+ alt,
86
+ ctx,
87
+ }: {
88
+ src: string | null
89
+ alt: string
90
+ ctx: RenderContext
91
+ }) {
92
+ const className = "h-full w-full object-cover"
93
+ if (!src) {
94
+ return (
95
+ <div
96
+ role="img"
97
+ aria-label={alt}
98
+ className={className}
99
+ style={{ background: "var(--cms-superficie,#f4f4f4)" }}
100
+ />
101
+ )
102
+ }
103
+ const { Imagen } = ctx.componentes
104
+ return <Imagen src={src} alt={alt} className={className} />
105
+ }
106
+
107
+ // ── Línea del carrito ────────────────────────────────────────────────────────
108
+
109
+ function LineaCarrito({
110
+ linea,
111
+ moneda,
112
+ ctx,
113
+ }: {
114
+ linea: CartLineData
115
+ moneda: string
116
+ ctx: RenderContext
117
+ }) {
118
+ const { Enlace } = ctx.componentes
119
+ return (
120
+ <li className={`flex gap-3 py-4 ${claseBorde}`}>
121
+ <Enlace
122
+ href={linea.href}
123
+ className="block h-[88px] w-[68px] flex-none overflow-hidden [border-radius:var(--cms-radio,12px)]"
124
+ >
125
+ <Miniatura src={linea.thumbnail} alt={linea.title} ctx={ctx} />
126
+ </Enlace>
127
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
128
+ <Enlace href={linea.href} className="[color:var(--cms-texto,#0c0c10)]">
129
+ <span className="block truncate text-[13.5px] font-semibold" style={{ fontFamily: "var(--cms-fuente-texto)" }}>
130
+ {linea.title}
131
+ </span>
132
+ </Enlace>
133
+ {linea.variantTitle ? (
134
+ <span className="text-[12px]" style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
135
+ {linea.variantTitle}
136
+ </span>
137
+ ) : null}
138
+ <div className="mt-auto flex items-baseline justify-between gap-2 pt-1">
139
+ <span className="text-[12px]" style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
140
+ {linea.cantidad} ×
141
+ </span>
142
+ <span
143
+ className="text-[13px] font-semibold"
144
+ style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto,#0c0c10)" }}
145
+ >
146
+ {formatearMonto(linea.precioTotal, moneda)}
147
+ </span>
148
+ </div>
149
+ </div>
150
+ </li>
151
+ )
152
+ }
153
+
154
+ // ── Estado vacío ─────────────────────────────────────────────────────────────
155
+
156
+ function BolsaVacia({ ctx }: { ctx: RenderContext }) {
157
+ const { Enlace } = ctx.componentes
158
+ return (
159
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
160
+ <p className="text-[15px] font-semibold" style={{ color: "var(--cms-texto,#0c0c10)" }}>
161
+ Tu bolsa está vacía
162
+ </p>
163
+ <Enlace
164
+ href={URL_CATALOGO}
165
+ className="text-[13px] font-semibold underline underline-offset-4 [color:var(--cms-texto,#0c0c10)]"
166
+ >
167
+ Seguir comprando
168
+ </Enlace>
169
+ </div>
170
+ )
171
+ }
172
+
173
+ // ── Pie con subtotal + CTAs ──────────────────────────────────────────────────
174
+
175
+ function PieCarrito({ carrito, ctx }: { carrito: CartData; ctx: RenderContext }) {
176
+ const { Enlace } = ctx.componentes
177
+ return (
178
+ <div
179
+ className="mt-auto px-4 py-4"
180
+ style={{ borderTop: "var(--cms-grosor-borde,1px) solid var(--cms-borde,#dbd6c9)" }}
181
+ >
182
+ <div className="mb-3 flex items-baseline justify-between">
183
+ <span className="text-[13px] font-semibold uppercase tracking-[0.06em]" style={{ color: "var(--cms-texto,#0c0c10)" }}>
184
+ Subtotal
185
+ </span>
186
+ <span
187
+ className="text-[15px] font-bold"
188
+ style={{ fontFamily: "var(--cms-fuente-mono)", color: "var(--cms-texto,#0c0c10)" }}
189
+ >
190
+ {formatearMonto(carrito.subtotal, carrito.moneda)}
191
+ </span>
192
+ </div>
193
+ <div className="flex flex-col gap-2.5">
194
+ <Enlace
195
+ href={carrito.urlCheckout}
196
+ className="block w-full py-3.5 text-center text-xs font-semibold uppercase tracking-[0.06em] [background:var(--cms-primario,#3b2bff)] [color:var(--cms-primario-texto,#fff)] [border-radius:var(--cms-radio-md,14px)] [font-family:var(--cms-fuente-texto)]"
197
+ >
198
+ Ir a pagar
199
+ </Enlace>
200
+ <Enlace
201
+ href={carrito.urlCarrito}
202
+ className="block w-full py-3 text-center text-xs font-semibold uppercase tracking-[0.06em] [border:var(--cms-grosor-borde,1px)_solid_var(--cms-borde,#dbd6c9)] [color:var(--cms-texto,#0c0c10)] [border-radius:var(--cms-radio-md,14px)] [font-family:var(--cms-fuente-texto)]"
203
+ >
204
+ Ver bolsa
205
+ </Enlace>
206
+ </div>
207
+ </div>
208
+ )
209
+ }
210
+
211
+ // ── Drawer ─────────────────────────────────────────────────────────────────────
212
+
213
+ export function MiniCart({
214
+ abierto,
215
+ onCerrar,
216
+ carrito,
217
+ ctx,
218
+ }: {
219
+ abierto: boolean
220
+ onCerrar: () => void
221
+ carrito: CartData | null
222
+ ctx: RenderContext
223
+ }) {
224
+ const panelRef = useRef<HTMLDivElement>(null)
225
+ const cerrarRef = useRef<HTMLButtonElement>(null)
226
+
227
+ // Esc para cerrar + foco atrapado (Tab cicla) + scroll del body bloqueado.
228
+ useEffect(() => {
229
+ if (!abierto) return
230
+ cerrarRef.current?.focus()
231
+
232
+ const previo = document.body.style.overflow
233
+ document.body.style.overflow = "hidden"
234
+
235
+ const onKeyDown = (e: KeyboardEvent) => {
236
+ if (e.key === "Escape") {
237
+ onCerrar()
238
+ return
239
+ }
240
+ if (e.key !== "Tab") return
241
+ const panel = panelRef.current
242
+ if (!panel) return
243
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>(SELECTOR_FOCO)).filter(
244
+ (el) => el.offsetParent !== null || el === document.activeElement,
245
+ )
246
+ if (focusables.length === 0) return
247
+ const primero = focusables[0]
248
+ const ultimo = focusables[focusables.length - 1]
249
+ if (e.shiftKey && document.activeElement === primero) {
250
+ e.preventDefault()
251
+ ultimo.focus()
252
+ } else if (!e.shiftKey && document.activeElement === ultimo) {
253
+ e.preventDefault()
254
+ primero.focus()
255
+ }
256
+ }
257
+
258
+ document.addEventListener("keydown", onKeyDown)
259
+ return () => {
260
+ document.removeEventListener("keydown", onKeyDown)
261
+ document.body.style.overflow = previo
262
+ }
263
+ }, [abierto, onCerrar])
264
+
265
+ if (!abierto) return null
266
+
267
+ const items = carrito?.items ?? []
268
+ const vacia = items.length === 0
269
+ const count = carrito?.count ?? 0
270
+
271
+ return (
272
+ <>
273
+ <EstiloMiniCart />
274
+ <div className="cms-minicart-scrim fixed inset-0 z-40 bg-black/50" onClick={onCerrar} aria-hidden />
275
+ <div
276
+ ref={panelRef}
277
+ role="dialog"
278
+ aria-modal="true"
279
+ aria-label="Tu bolsa"
280
+ className="cms-minicart-panel fixed right-0 top-0 z-50 flex h-full w-[90%] max-w-[400px] flex-col"
281
+ style={{
282
+ background: "var(--cms-superficie,#fff)",
283
+ color: "var(--cms-texto,#0c0c10)",
284
+ boxShadow: "var(--cms-sombra-lg,0 30px 70px -28px rgba(12,12,16,.4))",
285
+ }}
286
+ >
287
+ <div className={`flex items-center justify-between px-4 py-4 ${claseBorde}`}>
288
+ <h2
289
+ className="text-[15px] font-bold uppercase tracking-[0.04em]"
290
+ style={{ fontFamily: "var(--cms-fuente-titulo)", color: "var(--cms-texto,#0c0c10)" }}
291
+ >
292
+ Tu bolsa ({count})
293
+ </h2>
294
+ <button
295
+ ref={cerrarRef}
296
+ type="button"
297
+ onClick={onCerrar}
298
+ aria-label="Cerrar bolsa"
299
+ className="inline-flex items-center justify-center leading-none transition-opacity hover:opacity-70"
300
+ >
301
+ <IconoCerrar />
302
+ </button>
303
+ </div>
304
+
305
+ {vacia ? (
306
+ <BolsaVacia ctx={ctx} />
307
+ ) : (
308
+ <>
309
+ <ul className="flex flex-1 flex-col overflow-y-auto px-4">
310
+ {items.map((linea) => (
311
+ <LineaCarrito key={linea.id} linea={linea} moneda={carrito!.moneda} ctx={ctx} />
312
+ ))}
313
+ </ul>
314
+ <PieCarrito carrito={carrito!} ctx={ctx} />
315
+ </>
316
+ )}
317
+ </div>
318
+ </>
319
+ )
320
+ }
@@ -0,0 +1,19 @@
1
+ import type { CmsPageData } from "@arroyavecommerce/cms-core"
2
+ import { homeBlocks } from "../blocks"
3
+
4
+ export const DEFAULT_HOME_PAGE: CmsPageData = {
5
+ version: 1,
6
+ blocks: [
7
+ { id: "b1", type: "hero", props: homeBlocks.hero.defaults },
8
+ { id: "b2", type: "categoryTiles", props: homeBlocks.categoryTiles.defaults },
9
+ { id: "b3", type: "productGrid", props: homeBlocks.productGrid.defaults },
10
+ { id: "b4", type: "editorialBanner", props: homeBlocks.editorialBanner.defaults },
11
+ { id: "b5", type: "lookbook", props: homeBlocks.lookbook.defaults },
12
+ { id: "b6", type: "features", props: homeBlocks.features.defaults },
13
+ { id: "b7", type: "marquee", props: homeBlocks.marquee.defaults },
14
+ { id: "b8", type: "featuredCollection", props: homeBlocks.featuredCollection.defaults },
15
+ { id: "b9", type: "testimonials", props: homeBlocks.testimonials.defaults },
16
+ { id: "b10", type: "faq", props: homeBlocks.faq.defaults },
17
+ { id: "b11", type: "newsletter", props: homeBlocks.newsletter.defaults },
18
+ ],
19
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { cmsPageDataSchema } from "@arroyavecommerce/cms-core"
3
+ import { seedBundle, DEFAULT_CHROME_CONFIG } from "./seed"
4
+ import { themeKinetic } from "../index"
5
+
6
+ describe("seedBundle", () => {
7
+ const b = seedBundle()
8
+
9
+ it("theme son los tokens por defecto del template", () => {
10
+ expect(b.theme).toBe(themeKinetic.theme.defaults)
11
+ expect(typeof b.theme["fuente-titulo"]).toBe("string")
12
+ })
13
+
14
+ it("home pasa el schema de página del CMS", () => {
15
+ expect(cmsPageDataSchema.safeParse(b.home).success).toBe(true)
16
+ })
17
+
18
+ it("chrome referencia variantes que existen en el registry", () => {
19
+ expect(Object.keys(themeKinetic.chrome!.headers)).toContain(b.chrome.header.variante)
20
+ expect(Object.keys(themeKinetic.chrome!.footers)).toContain(b.chrome.footer.variante)
21
+ })
22
+
23
+ it("chrome trae un menú genérico (sin categorías específicas de tienda)", () => {
24
+ expect(b.chrome.menu.items.length).toBeGreaterThan(0)
25
+ expect(b.chrome.menu.items[0]).toEqual({ etiqueta: "Novedades", href: "/catalogo?sortBy=created_at" })
26
+ // El menú NO debe apuntar a categorías que una tienda nueva no tiene.
27
+ expect(b.chrome.menu.items.every((i) => !i.href.startsWith("/categories/"))).toBe(true)
28
+ })
29
+
30
+ it("DEFAULT_CHROME_CONFIG es data pura versionada", () => {
31
+ expect(DEFAULT_CHROME_CONFIG.version).toBe(1)
32
+ })
33
+ })
@@ -0,0 +1,28 @@
1
+ import { THEME_DEFAULTS } from "../theme"
2
+ import { DEFAULT_HOME_PAGE } from "./home"
3
+
4
+ /**
5
+ * Chrome-config por defecto que se siembra en una tienda nueva. Es DATA pura
6
+ * (no el registry de componentes). El menú es GENÉRICO a propósito: una tienda
7
+ * recién creada no tiene categorías camisetas/buzos; el dueño agrega enlaces de
8
+ * categoría desde el CMS.
9
+ */
10
+ export const DEFAULT_CHROME_CONFIG = {
11
+ version: 1 as const,
12
+ header: { variante: "inline", props: {} as Record<string, unknown> },
13
+ footer: { variante: "moda", props: {} as Record<string, unknown> },
14
+ menu: { items: [{ etiqueta: "Novedades", href: "/catalogo?sortBy=created_at" }] },
15
+ }
16
+
17
+ /**
18
+ * Bundle de semilla del template: tema + chrome + home, todo serializable. Lo
19
+ * sirve el storefront en `/api/cms/seed-defaults`; el orquestador lo GETea y lo
20
+ * publica en el backend de la tienda (fase `cms-seed`). NO importa React/Puck.
21
+ */
22
+ export function seedBundle() {
23
+ return {
24
+ theme: THEME_DEFAULTS,
25
+ chrome: DEFAULT_CHROME_CONFIG,
26
+ home: DEFAULT_HOME_PAGE,
27
+ }
28
+ }
package/src/context.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { HttpTypes } from "@medusajs/types"
2
+ import type { AriaAttributes, ComponentType, ReactNode } from "react"
3
+ import type { CmsFiltersConfig } from "@arroyavecommerce/cms-core"
4
+
5
+ /**
6
+ * Una línea (item) del carrito, ya normalizada para pintar en el mini-cart.
7
+ * Es DATA serializable (no funciones): el host la cablea desde el carrito de
8
+ * Medusa en el `RenderContext`.
9
+ */
10
+ export type CartLineData = {
11
+ id: string
12
+ title: string // título del producto
13
+ variantTitle: string | null // ej. "Crema / M"
14
+ thumbnail: string | null
15
+ cantidad: number
16
+ precioTotal: number // total de la línea, monto CRUDO (mismo formato que calculated_price.calculated_amount)
17
+ href: string // /products/:handle
18
+ }
19
+
20
+ /**
21
+ * El carrito completo, normalizado para el mini-cart drawer. `null` en el
22
+ * `RenderContext` cuando no hay carrito o está vacío. Los montos son CRUDOS
23
+ * (misma convención que `calculated_price.calculated_amount`); el formateo con
24
+ * `Intl.NumberFormat` ocurre en la vista.
25
+ */
26
+ export type CartData = {
27
+ items: CartLineData[]
28
+ count: number // suma de cantidades
29
+ subtotal: number // monto crudo
30
+ moneda: string // currency_code (ej. "cop")
31
+ urlCarrito: string // p.ej. "/cart"
32
+ urlCheckout: string // p.ej. "/checkout"
33
+ }
34
+
35
+ export type RenderContext = {
36
+ countryCode: string
37
+ region: HttpTypes.StoreRegion
38
+ products: HttpTypes.StoreProduct[]
39
+ categories: HttpTypes.StoreProductCategory[]
40
+ collections: HttpTypes.StoreCollection[]
41
+ cartQuantities: Record<string, number>
42
+ /**
43
+ * Carrito normalizado para el mini-cart drawer del header. `null` si está
44
+ * vacío o no hay carrito. Es un campo de DATOS serializable (como `products`
45
+ * o `cartQuantities`), NO una función. `cartQuantities` se mantiene para
46
+ * back-compat (badge de conteo).
47
+ */
48
+ carrito: CartData | null
49
+ acciones: {
50
+ agregarAlCarrito: (variantId: string, cantidad?: number) => Promise<{ ok: boolean }>
51
+ }
52
+ componentes: {
53
+ Imagen: ComponentType<{ src: string; alt: string; className?: string; prioridad?: boolean; sizes?: string }>
54
+ // La cáscara (next/link) reenvía aria-current nativamente al <a>.
55
+ Enlace: ComponentType<{
56
+ href: string
57
+ className?: string
58
+ children: ReactNode
59
+ "aria-current"?: AriaAttributes["aria-current"]
60
+ }>
61
+ }
62
+ sesion: { autenticado: boolean; nombre?: string } | null
63
+ catalogo?: {
64
+ listado?: {
65
+ titulo: string
66
+ breadcrumb: { texto: string; href: string }[]
67
+ productos: HttpTypes.StoreProduct[]
68
+ total: number
69
+ filtros: CmsFiltersConfig
70
+ seleccion: Record<string, string[]>
71
+ opcionesFaceta: Record<string, { valor: string; etiqueta: string; conteo?: number }[]>
72
+ paginacion: { pagina: number; totalPaginas: number; hrefPara: (p: number) => string }
73
+ urlFiltro: (facetId: string, valor: string) => string
74
+ urlOrden: (valor: string) => string
75
+ orden: string
76
+ }
77
+ detalle?: {
78
+ producto: {
79
+ eyebrow?: string
80
+ titulo: string
81
+ descripcion: string
82
+ precio: string
83
+ compareAt?: string
84
+ descuentoPct?: number
85
+ imagenes: { url: string; alt: string }[]
86
+ rating?: { promedio: number; total: number }
87
+ envioInfo?: string[]
88
+ acordeon?: { titulo: string; cuerpo: string }[]
89
+ }
90
+ variantes: {
91
+ color?: { valor: string; etiqueta: string; swatchUrl?: string; hex?: string }[]
92
+ talla?: { valor: string; etiqueta: string; agotada: boolean; pocasUnidades?: number }[]
93
+ }
94
+ resolverVariante: (sel: { color?: string; talla?: string }) => string | null
95
+ reseñas?: {
96
+ promedio: number
97
+ total: number
98
+ histograma: { estrellas: number; porcentaje: number }[]
99
+ muestra: { autor: string; estrellas: number; verificada: boolean; talla?: string; texto: string }[]
100
+ }
101
+ relacionados?: HttpTypes.StoreProduct[]
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import {
4
+ PAYMENT_ICONS,
5
+ SOCIAL_ICONS,
6
+ type PaymentKey,
7
+ type SocialKey,
8
+ } from "./index"
9
+
10
+ const PAYMENT_KEYS: PaymentKey[] = [
11
+ "visa",
12
+ "mastercard",
13
+ "amex",
14
+ "diners",
15
+ "pse",
16
+ "nequi",
17
+ "daviplata",
18
+ "bancolombia",
19
+ "efecty",
20
+ "addi",
21
+ "mercadopago",
22
+ "paypal",
23
+ "efectivo",
24
+ "applepay",
25
+ "googlepay",
26
+ "maestro",
27
+ "discover",
28
+ "bitcoin",
29
+ ]
30
+
31
+ const SOCIAL_KEYS: SocialKey[] = [
32
+ "instagram",
33
+ "tiktok",
34
+ "whatsapp",
35
+ "x",
36
+ "facebook",
37
+ "youtube",
38
+ "threads",
39
+ "pinterest",
40
+ "linkedin",
41
+ ]
42
+
43
+ describe("registries de iconos", () => {
44
+ it("PAYMENT_ICONS tiene exactamente las 18 claves esperadas", () => {
45
+ expect(Object.keys(PAYMENT_ICONS).sort()).toEqual([...PAYMENT_KEYS].sort())
46
+ })
47
+
48
+ it("SOCIAL_ICONS tiene exactamente las 9 claves esperadas", () => {
49
+ expect(Object.keys(SOCIAL_ICONS).sort()).toEqual([...SOCIAL_KEYS].sort())
50
+ })
51
+
52
+ it("cada entrada expone un label no vacío y un Icon", () => {
53
+ for (const { label, Icon } of [
54
+ ...Object.values(PAYMENT_ICONS),
55
+ ...Object.values(SOCIAL_ICONS),
56
+ ]) {
57
+ expect(typeof label).toBe("string")
58
+ expect(label.length).toBeGreaterThan(0)
59
+ expect(typeof Icon).toBe("function")
60
+ }
61
+ })
62
+ })
63
+
64
+ describe("render de cada icono", () => {
65
+ const todos = [
66
+ ...Object.entries(PAYMENT_ICONS).map(([k, v]) => [`payment:${k}`, v.Icon] as const),
67
+ ...Object.entries(SOCIAL_ICONS).map(([k, v]) => [`social:${k}`, v.Icon] as const),
68
+ ]
69
+
70
+ it.each(todos)("%s renderiza un <svg>", (_nombre, Icon) => {
71
+ const html = renderToStaticMarkup(<Icon />)
72
+ expect(html).toContain("<svg")
73
+ expect(html).toContain("</svg>")
74
+ })
75
+
76
+ it("aplica title como aria-label y <title> cuando se pasa", () => {
77
+ const { Icon } = PAYMENT_ICONS.visa
78
+ const html = renderToStaticMarkup(<Icon title="Pago con Visa" />)
79
+ expect(html).toContain('aria-label="Pago con Visa"')
80
+ expect(html).toContain("<title>Pago con Visa</title>")
81
+ })
82
+
83
+ it("sin title el icono es decorativo (aria-hidden)", () => {
84
+ const { Icon } = PAYMENT_ICONS.visa
85
+ const html = renderToStaticMarkup(<Icon />)
86
+ expect(html).toContain('aria-hidden="true"')
87
+ })
88
+
89
+ it("respeta className y size", () => {
90
+ const { Icon } = SOCIAL_ICONS.whatsapp
91
+ const html = renderToStaticMarkup(<Icon className="mi-clase" size={28} />)
92
+ expect(html).toContain("mi-clase")
93
+ expect(html).toContain("28")
94
+ })
95
+ })
96
+
97
+ describe("tintado por data-brand", () => {
98
+ it("al menos un icono de pago expone un elemento con data-brand", () => {
99
+ const conMarca = Object.values(PAYMENT_ICONS).filter((entry) =>
100
+ renderToStaticMarkup(<entry.Icon />).includes("data-brand"),
101
+ )
102
+ expect(conMarca.length).toBeGreaterThan(0)
103
+ })
104
+
105
+ it("todos los iconos de pago exponen al menos un data-brand", () => {
106
+ for (const [key, entry] of Object.entries(PAYMENT_ICONS)) {
107
+ const html = renderToStaticMarkup(<entry.Icon />)
108
+ expect(html, `${key} debería tener data-brand`).toContain("data-brand")
109
+ }
110
+ })
111
+
112
+ it("todos los iconos sociales exponen al menos un data-brand", () => {
113
+ for (const [key, entry] of Object.entries(SOCIAL_ICONS)) {
114
+ const html = renderToStaticMarkup(<entry.Icon />)
115
+ expect(html, `${key} debería tener data-brand`).toContain("data-brand")
116
+ }
117
+ })
118
+ })