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