@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,603 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { z } from "zod"
5
+ import type { ChromeVariantDef } from "@arroyavecommerce/cms-core"
6
+ import type { RenderContext } from "../context"
7
+ import { PAYMENT_ICONS, SOCIAL_ICONS, type PaymentKey, type SocialKey } from "../icons"
8
+
9
+ /**
10
+ * Footers de la plantilla de moda — estética "Kinetic Street" (grotesca pesada,
11
+ * cobalto/coral, sombras y radios suaves). Todo se pinta con tokens `--cms-*`
12
+ * CON fallback, así el footer se ve bien aunque el tema no publique el token.
13
+ *
14
+ * 100% editable/combinable desde el CMS: cada variante es un `ChromeVariantDef`
15
+ * con su schema Zod, y el ChromeEditor autogenera los controles. Los campos de
16
+ * iconos usan la convención `.describe()` del form-builder:
17
+ * - `z.array(z.enum(PAYMENT_KEYS)).describe("icons:payment")` → icon-picker
18
+ * - `z.array({ red, href }).describe("icons:social")` → selector red
19
+ * - `z.enum(...).describe("segmented")` → pills
20
+ *
21
+ * A diferencia del editor, el footer SÍ importa la librería de iconos
22
+ * (`../icons`) porque vive dentro de `theme-kinetic`.
23
+ */
24
+
25
+ const PAYMENT_KEYS = Object.keys(PAYMENT_ICONS) as [PaymentKey, ...PaymentKey[]]
26
+ const SOCIAL_KEYS = Object.keys(SOCIAL_ICONS) as [SocialKey, ...SocialKey[]]
27
+
28
+ // ── Schemas compartidos ──────────────────────────────────────────────────────
29
+
30
+ const logoSchema = z.object({
31
+ tipo: z.enum(["texto", "imagen"]).describe("segmented"),
32
+ texto: z.string(),
33
+ url: z.string(),
34
+ })
35
+ type LogoConfig = z.infer<typeof logoSchema>
36
+
37
+ const enlaceSchema = z.object({ texto: z.string().min(1), href: z.string().min(1) })
38
+ const columnaSchema = z.object({ titulo: z.string().min(1), enlaces: z.array(enlaceSchema) })
39
+
40
+ const pagosSchema = z.object({
41
+ metodos: z.array(z.enum(PAYMENT_KEYS)).describe("icons:payment"),
42
+ modo: z.enum(["icono", "texto"]).describe("segmented"),
43
+ color: z.enum(["marca", "tinta", "acento"]).describe("segmented"),
44
+ })
45
+ type PagosConfig = z.infer<typeof pagosSchema>
46
+
47
+ const redesSchema = z.object({
48
+ items: z
49
+ .array(z.object({ red: z.enum(SOCIAL_KEYS), href: z.string().min(1) }))
50
+ .describe("icons:social"),
51
+ modo: z.enum(["icono", "texto"]).describe("segmented"),
52
+ color: z.enum(["marca", "tinta", "acento"]).describe("segmented"),
53
+ })
54
+ type RedesConfig = z.infer<typeof redesSchema>
55
+
56
+ type ColorIcono = PagosConfig["color"]
57
+
58
+ // ── Utilidades de tintado (convención de `svg-base.tsx`) ─────────────────────
59
+
60
+ function claseColor(color: ColorIcono): string {
61
+ return color === "tinta" ? "icons--tinta" : color === "acento" ? "icons--acento" : ""
62
+ }
63
+
64
+ function colorTexto(color: ColorIcono): string {
65
+ return color === "acento" ? "var(--cms-acento,#3b2bff)" : "var(--cms-texto,#151515)"
66
+ }
67
+
68
+ /**
69
+ * Reglas CSS del tintado monocromo (documentadas en `svg-base.tsx`). No hay
70
+ * hoja global que las defina, así que el footer las emite una vez.
71
+ */
72
+ function EstiloIconos() {
73
+ return (
74
+ <style
75
+ dangerouslySetInnerHTML={{
76
+ __html:
77
+ ".icons--tinta [data-brand]{fill:currentColor;stroke:currentColor}" +
78
+ ".icons--acento [data-brand]{fill:var(--cms-acento,#3b2bff);stroke:var(--cms-acento,#3b2bff)}",
79
+ }}
80
+ />
81
+ )
82
+ }
83
+
84
+ // ── Piezas de render compartidas ─────────────────────────────────────────────
85
+
86
+ /** Logo real del chrome: imagen (via ctx.componentes.Imagen) o wordmark de texto. */
87
+ function LogoChrome({ logo, ctx }: { logo: LogoConfig; ctx: RenderContext }) {
88
+ const { Imagen, Enlace } = ctx.componentes
89
+ return (
90
+ <Enlace href="/" className="inline-flex items-center">
91
+ {logo.tipo === "imagen" && logo.url ? (
92
+ <Imagen src={logo.url} alt={logo.texto || "Logo"} className="h-8 w-auto object-contain" />
93
+ ) : (
94
+ <span
95
+ className="text-[24px] leading-none"
96
+ style={{
97
+ fontFamily: "var(--cms-fuente-titulo)",
98
+ fontWeight: "var(--cms-titulo-peso,900)",
99
+ textTransform: "var(--cms-titulo-transform,uppercase)",
100
+ letterSpacing: "var(--cms-titulo-espaciado,-0.04em)",
101
+ color: "var(--cms-texto,#0c0c10)",
102
+ }}
103
+ >
104
+ {logo.texto || "Tienda"}
105
+ </span>
106
+ )}
107
+ </Enlace>
108
+ )
109
+ }
110
+
111
+ /** Métodos de pago: icono o texto, con tintado marca/tinta/acento. */
112
+ function Pagos({ pagos }: { pagos: PagosConfig }) {
113
+ if (!pagos.metodos.length) return null
114
+ return (
115
+ <div
116
+ className={`flex flex-wrap items-center gap-2 ${claseColor(pagos.color)}`}
117
+ style={{ color: colorTexto(pagos.color) }}
118
+ aria-label="Métodos de pago"
119
+ >
120
+ {pagos.metodos.map((clave) => {
121
+ const entry = PAYMENT_ICONS[clave]
122
+ if (!entry) return null
123
+ const Icono = entry.Icon
124
+ return (
125
+ <span
126
+ key={clave}
127
+ className="inline-flex items-center justify-center"
128
+ style={{
129
+ padding: pagos.modo === "icono" ? "6px 9px" : "5px 9px",
130
+ borderRadius: "var(--cms-radio-md,12px)",
131
+ border: "1px solid var(--cms-borde,#e6e4df)",
132
+ background: "var(--cms-superficie,#fff)",
133
+ boxShadow: "var(--cms-sombra-sm,0 1px 2px rgba(0,0,0,.06))",
134
+ fontSize: "10px",
135
+ lineHeight: 1,
136
+ ...(pagos.modo === "texto"
137
+ ? {
138
+ fontWeight: 600,
139
+ textTransform: "uppercase" as const,
140
+ letterSpacing: "0.04em",
141
+ fontFamily: "var(--cms-fuente-mono)",
142
+ }
143
+ : {}),
144
+ }}
145
+ >
146
+ {pagos.modo === "icono" ? <Icono size={22} title={entry.label} /> : entry.label}
147
+ </span>
148
+ )
149
+ })}
150
+ </div>
151
+ )
152
+ }
153
+
154
+ /** Redes sociales: cada una enlaza (ctx.componentes.Enlace); icono o texto. */
155
+ function Redes({ redes, ctx }: { redes: RedesConfig; ctx: RenderContext }) {
156
+ if (!redes.items.length) return null
157
+ const { Enlace } = ctx.componentes
158
+ return (
159
+ <div
160
+ className={`flex flex-wrap items-center gap-2.5 ${claseColor(redes.color)}`}
161
+ style={{ color: colorTexto(redes.color) }}
162
+ >
163
+ {redes.items.map((item, i) => {
164
+ const entry = SOCIAL_ICONS[item.red]
165
+ if (!entry) return null
166
+ const Icono = entry.Icon
167
+ return (
168
+ <Enlace key={`${item.red}-${i}`} href={item.href} className="inline-flex items-center">
169
+ {redes.modo === "icono" ? (
170
+ <span
171
+ className="inline-flex h-9 w-9 items-center justify-center"
172
+ style={{
173
+ borderRadius: "var(--cms-radio-full,999px)",
174
+ border: "1px solid var(--cms-borde,#e6e4df)",
175
+ background: "var(--cms-superficie,#fff)",
176
+ boxShadow: "var(--cms-sombra-sm,0 1px 2px rgba(0,0,0,.06))",
177
+ }}
178
+ >
179
+ <Icono size={18} title={entry.label} />
180
+ </span>
181
+ ) : (
182
+ <span className="text-xs font-semibold uppercase tracking-[0.06em]">{entry.label}</span>
183
+ )}
184
+ </Enlace>
185
+ )
186
+ })}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ const claseTituloColumna = "text-[11px] font-semibold uppercase tracking-[0.1em]"
192
+
193
+ function ColumnaDesktop({
194
+ columna,
195
+ ctx,
196
+ }: {
197
+ columna: z.infer<typeof columnaSchema>
198
+ ctx: RenderContext
199
+ }) {
200
+ const { Enlace } = ctx.componentes
201
+ return (
202
+ <div className="flex flex-col gap-2.5 text-[12.5px]">
203
+ <h3 className={claseTituloColumna} style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
204
+ {columna.titulo}
205
+ </h3>
206
+ <nav aria-label={columna.titulo}>
207
+ {columna.enlaces.map((enlace) => (
208
+ <Enlace key={enlace.href} href={enlace.href} className="block">
209
+ {enlace.texto}
210
+ </Enlace>
211
+ ))}
212
+ </nav>
213
+ </div>
214
+ )
215
+ }
216
+
217
+ function ColumnaAcordeon({
218
+ columna,
219
+ abierta,
220
+ onToggle,
221
+ ctx,
222
+ }: {
223
+ columna: z.infer<typeof columnaSchema>
224
+ abierta: boolean
225
+ onToggle: () => void
226
+ ctx: RenderContext
227
+ }) {
228
+ const { Enlace } = ctx.componentes
229
+ return (
230
+ <div style={{ borderBottom: "1px solid var(--cms-borde,#dbd6c9)" }}>
231
+ <button
232
+ type="button"
233
+ onClick={onToggle}
234
+ aria-expanded={abierta}
235
+ className={`flex w-full items-center justify-between bg-transparent py-3.5 ${claseTituloColumna}`}
236
+ style={{ color: "var(--cms-texto,#0c0c10)" }}
237
+ >
238
+ <h3 className="m-0">{columna.titulo}</h3>
239
+ <span className={`transition-transform ${abierta ? "rotate-90" : ""}`}>›</span>
240
+ </button>
241
+ {abierta ? (
242
+ <nav
243
+ aria-label={columna.titulo}
244
+ className="flex flex-col gap-2.5 pb-3.5 text-sm"
245
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
246
+ >
247
+ {columna.enlaces.map((enlace) => (
248
+ <Enlace key={enlace.href} href={enlace.href} className="block">
249
+ {enlace.texto}
250
+ </Enlace>
251
+ ))}
252
+ </nav>
253
+ ) : null}
254
+ </div>
255
+ )
256
+ }
257
+
258
+ function ColumnasMobile({
259
+ columnas,
260
+ ctx,
261
+ }: {
262
+ columnas: z.infer<typeof columnaSchema>[]
263
+ ctx: RenderContext
264
+ }) {
265
+ const [abierta, setAbierta] = useState<string | null>(null)
266
+ return (
267
+ <div className="flex flex-col">
268
+ {columnas.map((columna) => (
269
+ <ColumnaAcordeon
270
+ key={columna.titulo}
271
+ columna={columna}
272
+ abierta={abierta === columna.titulo}
273
+ onToggle={() => setAbierta((a) => (a === columna.titulo ? null : columna.titulo))}
274
+ ctx={ctx}
275
+ />
276
+ ))}
277
+ </div>
278
+ )
279
+ }
280
+
281
+ /** Barra fina de degradado kinético en el borde superior del footer. */
282
+ function BarraKinetic() {
283
+ return (
284
+ <div
285
+ aria-hidden
286
+ style={{
287
+ height: "4px",
288
+ background: "var(--cms-degradado,linear-gradient(120deg,#3b2bff,#ff3b2e))",
289
+ }}
290
+ />
291
+ )
292
+ }
293
+
294
+ function ShellFooter({ children }: { children: React.ReactNode }) {
295
+ return (
296
+ <footer
297
+ className="w-full"
298
+ style={{
299
+ background: "var(--cms-superficie,#fff)",
300
+ color: "var(--cms-texto,#0c0c10)",
301
+ borderTop: "1px solid var(--cms-borde,#dbd6c9)",
302
+ }}
303
+ >
304
+ <EstiloIconos />
305
+ <BarraKinetic />
306
+ {children}
307
+ </footer>
308
+ )
309
+ }
310
+
311
+ // ── Variante: MODA (mejorada) ────────────────────────────────────────────────
312
+
313
+ const modaSchema = z.object({
314
+ logo: logoSchema,
315
+ tagline: z.string(),
316
+ columnas: z.array(columnaSchema),
317
+ pagos: pagosSchema,
318
+ redes: redesSchema,
319
+ legal: z.string().min(1),
320
+ })
321
+ export type ModaProps = z.infer<typeof modaSchema>
322
+ export type FooterProps = ModaProps
323
+
324
+ const columnasDefault: ModaProps["columnas"] = [
325
+ {
326
+ titulo: "Tienda",
327
+ enlaces: [
328
+ { texto: "Novedades", href: "/novedades" },
329
+ { texto: "Ropa", href: "/ropa" },
330
+ { texto: "Zapatos", href: "/zapatos" },
331
+ { texto: "Accesorios", href: "/accesorios" },
332
+ ],
333
+ },
334
+ {
335
+ titulo: "Ayuda",
336
+ enlaces: [
337
+ { texto: "Envíos y devoluciones", href: "/ayuda/envios-y-devoluciones" },
338
+ { texto: "Guía de tallas", href: "/ayuda/guia-de-tallas" },
339
+ { texto: "Rastrear pedido", href: "/ayuda/rastrear-pedido" },
340
+ { texto: "Contacto", href: "/ayuda/contacto" },
341
+ ],
342
+ },
343
+ {
344
+ titulo: "Nosotros",
345
+ enlaces: [
346
+ { texto: "Términos y condiciones", href: "/legal/terminos" },
347
+ { texto: "Política de datos", href: "/legal/politica-de-datos" },
348
+ { texto: "PQRS", href: "/legal/pqrs" },
349
+ ],
350
+ },
351
+ ]
352
+
353
+ const pagosDefault: PagosConfig = {
354
+ metodos: ["visa", "mastercard", "amex", "pse", "nequi", "addi"],
355
+ modo: "icono",
356
+ color: "marca",
357
+ }
358
+
359
+ const redesDefault: RedesConfig = {
360
+ items: [
361
+ { red: "instagram", href: "https://instagram.com/moda" },
362
+ { red: "tiktok", href: "https://tiktok.com/@moda" },
363
+ { red: "whatsapp", href: "https://wa.me/573000000000" },
364
+ ],
365
+ modo: "icono",
366
+ color: "marca",
367
+ }
368
+
369
+ const modaDefaults: ModaProps = {
370
+ logo: { tipo: "texto", texto: "MODA", url: "" },
371
+ tagline: "Moda urbana del día a día. Envíos a toda Colombia.",
372
+ columnas: columnasDefault,
373
+ pagos: pagosDefault,
374
+ redes: redesDefault,
375
+ legal: "© 2026 Moda. Todos los derechos reservados.",
376
+ }
377
+
378
+ function FooterModa({ props, ctx }: { props: ModaProps; ctx: RenderContext }) {
379
+ const columnas = props.columnas.length || 1
380
+ return (
381
+ <ShellFooter>
382
+ <div className="mx-auto max-w-6xl px-5 py-10 md:px-10 md:py-14">
383
+ <div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
384
+ <div className="max-w-sm">
385
+ <LogoChrome logo={props.logo} ctx={ctx} />
386
+ <p
387
+ className="mt-3 text-[13px] leading-relaxed"
388
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
389
+ >
390
+ {props.tagline}
391
+ </p>
392
+ <div className="mt-5">
393
+ <Redes redes={props.redes} ctx={ctx} />
394
+ </div>
395
+ </div>
396
+ <div
397
+ className="hidden gap-10 md:grid"
398
+ style={{ gridTemplateColumns: `repeat(${columnas}, minmax(0,1fr))` }}
399
+ >
400
+ {props.columnas.map((columna) => (
401
+ <ColumnaDesktop key={columna.titulo} columna={columna} ctx={ctx} />
402
+ ))}
403
+ </div>
404
+ </div>
405
+
406
+ <div className="mt-6 md:hidden">
407
+ <ColumnasMobile columnas={props.columnas} ctx={ctx} />
408
+ </div>
409
+
410
+ <div
411
+ className="mt-10 flex flex-col gap-5 pt-6 md:flex-row md:items-center md:justify-between"
412
+ style={{ borderTop: "1px solid var(--cms-borde,#dbd6c9)" }}
413
+ >
414
+ <span className="text-[11.5px]" style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
415
+ {props.legal}
416
+ </span>
417
+ <Pagos pagos={props.pagos} />
418
+ </div>
419
+ </div>
420
+ </ShellFooter>
421
+ )
422
+ }
423
+
424
+ export const footerModa: ChromeVariantDef<ModaProps, RenderContext> = {
425
+ label: "Moda",
426
+ schema: modaSchema,
427
+ defaults: modaDefaults,
428
+ Component: ({ props, ctx }) => <FooterModa props={props} ctx={ctx} />,
429
+ }
430
+
431
+ // ── Variante: MINIMAL ────────────────────────────────────────────────────────
432
+
433
+ const minimalSchema = z.object({
434
+ logo: logoSchema,
435
+ enlaces: z.array(enlaceSchema),
436
+ redes: redesSchema,
437
+ legal: z.string().min(1),
438
+ })
439
+ export type MinimalProps = z.infer<typeof minimalSchema>
440
+
441
+ const minimalDefaults: MinimalProps = {
442
+ logo: { tipo: "texto", texto: "MODA", url: "" },
443
+ enlaces: [
444
+ { texto: "Tienda", href: "/novedades" },
445
+ { texto: "Ayuda", href: "/ayuda/contacto" },
446
+ { texto: "Términos", href: "/legal/terminos" },
447
+ { texto: "Privacidad", href: "/legal/politica-de-datos" },
448
+ ],
449
+ redes: { ...redesDefault, modo: "texto" },
450
+ legal: "© 2026 Moda. Todos los derechos reservados.",
451
+ }
452
+
453
+ function FooterMinimal({ props, ctx }: { props: MinimalProps; ctx: RenderContext }) {
454
+ const { Enlace } = ctx.componentes
455
+ return (
456
+ <ShellFooter>
457
+ <div className="mx-auto max-w-6xl px-5 py-8 md:px-10">
458
+ <div className="flex flex-col items-center gap-5 text-center md:flex-row md:justify-between md:text-left">
459
+ <LogoChrome logo={props.logo} ctx={ctx} />
460
+ <nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
461
+ {props.enlaces.map((enlace) => (
462
+ <Enlace
463
+ key={enlace.href}
464
+ href={enlace.href}
465
+ className="text-xs font-semibold uppercase tracking-[0.08em]"
466
+ >
467
+ {enlace.texto}
468
+ </Enlace>
469
+ ))}
470
+ </nav>
471
+ <Redes redes={props.redes} ctx={ctx} />
472
+ </div>
473
+ <p
474
+ className="mt-6 text-center text-[11.5px] md:text-left"
475
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
476
+ >
477
+ {props.legal}
478
+ </p>
479
+ </div>
480
+ </ShellFooter>
481
+ )
482
+ }
483
+
484
+ export const footerMinimal: ChromeVariantDef<MinimalProps, RenderContext> = {
485
+ label: "Minimal",
486
+ schema: minimalSchema,
487
+ defaults: minimalDefaults,
488
+ Component: ({ props, ctx }) => <FooterMinimal props={props} ctx={ctx} />,
489
+ }
490
+
491
+ // ── Variante: NEWSLETTER ─────────────────────────────────────────────────────
492
+
493
+ const newsletterSchema = z.object({
494
+ logo: logoSchema,
495
+ boletin: z.object({
496
+ titulo: z.string().min(1),
497
+ texto: z.string(),
498
+ placeholder: z.string(),
499
+ boton: z.string().min(1),
500
+ nota: z.string(),
501
+ }),
502
+ redes: redesSchema,
503
+ pagos: pagosSchema,
504
+ legal: z.string().min(1),
505
+ })
506
+ export type NewsletterProps = z.infer<typeof newsletterSchema>
507
+
508
+ const newsletterDefaults: NewsletterProps = {
509
+ logo: { tipo: "texto", texto: "MODA", url: "" },
510
+ boletin: {
511
+ titulo: "Sumate al drop",
512
+ texto: "Novedades, lanzamientos y ofertas antes que nadie. Sin spam.",
513
+ placeholder: "tu@correo.com",
514
+ boton: "Suscribirme",
515
+ nota: "Al suscribirte aceptás nuestra política de datos.",
516
+ },
517
+ redes: redesDefault,
518
+ pagos: pagosDefault,
519
+ legal: "© 2026 Moda. Todos los derechos reservados.",
520
+ }
521
+
522
+ function FooterNewsletter({ props, ctx }: { props: NewsletterProps; ctx: RenderContext }) {
523
+ // Envío = placeholder (sin backend en esta fase): solo evita el submit real.
524
+ return (
525
+ <ShellFooter>
526
+ <div className="mx-auto grid max-w-6xl gap-8 px-5 py-10 md:grid-cols-2 md:px-10 md:py-14">
527
+ <div>
528
+ <LogoChrome logo={props.logo} ctx={ctx} />
529
+ <h2
530
+ className="mt-4 text-[26px] leading-[1.05]"
531
+ style={{
532
+ fontFamily: "var(--cms-fuente-titulo)",
533
+ fontWeight: "var(--cms-titulo-peso,900)",
534
+ textTransform: "var(--cms-titulo-transform,uppercase)",
535
+ letterSpacing: "var(--cms-titulo-espaciado,-0.04em)",
536
+ }}
537
+ >
538
+ {props.boletin.titulo}
539
+ </h2>
540
+ <p
541
+ className="mt-2 max-w-md text-[13px] leading-relaxed"
542
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
543
+ >
544
+ {props.boletin.texto}
545
+ </p>
546
+ <form
547
+ className="mt-5 flex max-w-md flex-col gap-2 sm:flex-row"
548
+ onSubmit={(evento) => evento.preventDefault()}
549
+ >
550
+ <input
551
+ type="email"
552
+ placeholder={props.boletin.placeholder}
553
+ aria-label="Correo electrónico"
554
+ className="min-w-0 flex-1 px-3.5 py-2.5 text-sm outline-none"
555
+ style={{
556
+ borderRadius: "var(--cms-radio-md,12px)",
557
+ border: "1px solid var(--cms-borde,#dbd6c9)",
558
+ background: "var(--cms-fondo,#f1eee6)",
559
+ color: "var(--cms-texto,#0c0c10)",
560
+ }}
561
+ />
562
+ <button
563
+ type="submit"
564
+ className="px-5 py-2.5 text-sm font-semibold"
565
+ style={{
566
+ borderRadius: "var(--cms-radio-md,12px)",
567
+ background: "var(--cms-acento,#3b2bff)",
568
+ color: "var(--cms-primario-texto,#fff)",
569
+ boxShadow: "var(--cms-sombra-md,0 10px 30px -12px rgba(0,0,0,.18))",
570
+ }}
571
+ >
572
+ {props.boletin.boton}
573
+ </button>
574
+ </form>
575
+ <p className="mt-2 text-[11px]" style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
576
+ {props.boletin.nota}
577
+ </p>
578
+ </div>
579
+
580
+ <div className="flex flex-col gap-6 md:items-end">
581
+ <Redes redes={props.redes} ctx={ctx} />
582
+ <Pagos pagos={props.pagos} />
583
+ </div>
584
+ </div>
585
+
586
+ <div
587
+ className="mx-auto max-w-6xl px-5 py-4 md:px-10"
588
+ style={{ borderTop: "1px solid var(--cms-borde,#dbd6c9)" }}
589
+ >
590
+ <span className="text-[11.5px]" style={{ color: "var(--cms-texto-suave,#6b675e)" }}>
591
+ {props.legal}
592
+ </span>
593
+ </div>
594
+ </ShellFooter>
595
+ )
596
+ }
597
+
598
+ export const footerNewsletter: ChromeVariantDef<NewsletterProps, RenderContext> = {
599
+ label: "Newsletter",
600
+ schema: newsletterSchema,
601
+ defaults: newsletterDefaults,
602
+ Component: ({ props, ctx }) => <FooterNewsletter props={props} ctx={ctx} />,
603
+ }