@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,173 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { z } from "zod"
5
+ import type { RenderContext } from "../context"
6
+
7
+ /**
8
+ * Barra de anuncios de la plantilla de moda — estética "Kinetic Street".
9
+ * 100% editable/combinable desde el CMS: su schema Zod (con la convención
10
+ * `.describe("segmented")` del form-builder) vive acá y el header lo re-usa
11
+ * anidado en `HeaderProps.anuncio`, así el ChromeEditor autogenera los
12
+ * controles sin acoplar el header a la forma del anuncio.
13
+ *
14
+ * Todo se pinta con tokens `--cms-*` CON fallback: la barra se ve bien aunque
15
+ * el tema no publique el token.
16
+ *
17
+ * Variantes (`variante`):
18
+ * - `estatica` → todos los mensajes en fila, centrados.
19
+ * - `rotativa` → un mensaje a la vez; rota cada `velocidad` ms.
20
+ * - `marquesina` → scroll infinito CSS (respeta `prefers-reduced-motion`).
21
+ */
22
+
23
+ export const anuncioMensajeSchema = z.object({
24
+ texto: z.string().min(1),
25
+ href: z.string().optional(),
26
+ })
27
+ export type AnuncioMensaje = z.infer<typeof anuncioMensajeSchema>
28
+
29
+ export const anuncioSchema = z.object({
30
+ activo: z.boolean(),
31
+ variante: z.enum(["estatica", "rotativa", "marquesina"]).describe("segmented"),
32
+ mensajes: z.array(anuncioMensajeSchema),
33
+ color: z.enum(["acento", "degradado", "tinta", "fondo"]).describe("segmented"),
34
+ /** Rotativa: ms entre mensajes. Marquesina: ms del recorrido completo. */
35
+ velocidad: z.number(),
36
+ dismissible: z.boolean(),
37
+ })
38
+ export type Anuncio = z.infer<typeof anuncioSchema>
39
+
40
+ export const anuncioDefaults: Anuncio = {
41
+ activo: true,
42
+ variante: "rotativa",
43
+ mensajes: [
44
+ { texto: "Envío gratis desde $150.000" },
45
+ { texto: "Paga con PSE, tarjeta o efectivo" },
46
+ { texto: "Cambios fáciles en 30 días" },
47
+ ],
48
+ color: "acento",
49
+ velocidad: 4000,
50
+ dismissible: false,
51
+ }
52
+
53
+ const COLORES: Record<Anuncio["color"], { fondo: string; texto: string }> = {
54
+ acento: { fondo: "var(--cms-acento,#3b2bff)", texto: "var(--cms-primario-texto,#fff)" },
55
+ degradado: {
56
+ fondo: "var(--cms-degradado,linear-gradient(120deg,#3b2bff,#ff3b2e))",
57
+ texto: "var(--cms-primario-texto,#fff)",
58
+ },
59
+ tinta: { fondo: "var(--cms-texto,#0c0c10)", texto: "var(--cms-fondo,#f1eee6)" },
60
+ fondo: { fondo: "var(--cms-fondo,#f1eee6)", texto: "var(--cms-texto,#0c0c10)" },
61
+ }
62
+
63
+ const claseMensaje = "font-mono text-[10px] font-medium uppercase tracking-[0.12em] whitespace-nowrap"
64
+
65
+ /** Enlace no acepta `style`: el texto plano y el enlazado comparten las mismas clases. */
66
+ function Mensaje({ mensaje, ctx, className }: { mensaje: AnuncioMensaje; ctx: RenderContext; className?: string }) {
67
+ if (mensaje.href) {
68
+ const { Enlace } = ctx.componentes
69
+ return (
70
+ <Enlace href={mensaje.href} className={className}>
71
+ {mensaje.texto}
72
+ </Enlace>
73
+ )
74
+ }
75
+ return <span className={className}>{mensaje.texto}</span>
76
+ }
77
+
78
+ /** X de cierre para `dismissible` (SVG inline, sin dependencias). */
79
+ function BotonCerrar({ onClick }: { onClick: () => void }) {
80
+ return (
81
+ <button
82
+ type="button"
83
+ onClick={onClick}
84
+ aria-label="Cerrar anuncio"
85
+ className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center justify-center opacity-80 transition-opacity hover:opacity-100"
86
+ >
87
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
88
+ <path d="M6 6l12 12M18 6L6 18" />
89
+ </svg>
90
+ </button>
91
+ )
92
+ }
93
+
94
+ /** Marquesina: scroll infinito CSS. El track se duplica para un loop sin cortes. */
95
+ function Marquesina({
96
+ mensajes,
97
+ ctx,
98
+ duracionS,
99
+ }: {
100
+ mensajes: AnuncioMensaje[]
101
+ ctx: RenderContext
102
+ duracionS: number
103
+ }) {
104
+ const secuencia = [...mensajes, ...mensajes]
105
+ return (
106
+ <div className="cms-marq relative overflow-hidden">
107
+ <style
108
+ dangerouslySetInnerHTML={{
109
+ __html:
110
+ "@keyframes cms-marq-scroll{from{transform:translateX(0)}to{transform:translateX(-50%)}}" +
111
+ ".cms-marq-track{animation:cms-marq-scroll var(--cms-marq-dur,20s) linear infinite}" +
112
+ "@media (prefers-reduced-motion: reduce){.cms-marq-track{animation:none}}",
113
+ }}
114
+ />
115
+ <div
116
+ className={`cms-marq-track flex w-max items-center gap-10 ${claseMensaje}`}
117
+ style={{ "--cms-marq-dur": `${duracionS}s` } as React.CSSProperties}
118
+ >
119
+ {secuencia.map((mensaje, i) => (
120
+ <Mensaje key={`${mensaje.texto}-${i}`} mensaje={mensaje} ctx={ctx} />
121
+ ))}
122
+ </div>
123
+ </div>
124
+ )
125
+ }
126
+
127
+ export function AnnouncementBar({ anuncio, ctx }: { anuncio: Anuncio; ctx: RenderContext }) {
128
+ const [indice, setIndice] = useState(0)
129
+ const [cerrado, setCerrado] = useState(false)
130
+ const total = anuncio.mensajes.length
131
+ const esRotativa = anuncio.variante === "rotativa"
132
+ const velocidad = anuncio.velocidad > 0 ? anuncio.velocidad : 4000
133
+
134
+ useEffect(() => {
135
+ if (!anuncio.activo || !esRotativa || total <= 1) return
136
+ const id = setInterval(() => setIndice((actual) => (actual + 1) % total), velocidad)
137
+ return () => clearInterval(id)
138
+ }, [anuncio.activo, esRotativa, total, velocidad])
139
+
140
+ if (!anuncio.activo || total === 0 || cerrado) return null
141
+
142
+ const { fondo, texto } = COLORES[anuncio.color]
143
+ const paddingDerecha = anuncio.dismissible ? "pr-9" : ""
144
+
145
+ let contenido: React.ReactNode
146
+ if (anuncio.variante === "marquesina") {
147
+ // Recorrido completo (recorre el largo del track duplicado) en `velocidad` ms.
148
+ const duracionS = Math.max(6, (velocidad / 1000) * Math.max(total, 1))
149
+ contenido = <Marquesina mensajes={anuncio.mensajes} ctx={ctx} duracionS={duracionS} />
150
+ } else if (esRotativa) {
151
+ const mensajeActual = anuncio.mensajes[indice % total]
152
+ contenido = (
153
+ <div className={`flex items-center justify-center text-center ${claseMensaje}`}>
154
+ <Mensaje mensaje={mensajeActual} ctx={ctx} />
155
+ </div>
156
+ )
157
+ } else {
158
+ contenido = (
159
+ <div className={`flex flex-wrap items-center justify-center gap-x-6 gap-y-1 text-center ${claseMensaje}`}>
160
+ {anuncio.mensajes.map((mensaje, i) => (
161
+ <Mensaje key={`${mensaje.texto}-${i}`} mensaje={mensaje} ctx={ctx} />
162
+ ))}
163
+ </div>
164
+ )
165
+ }
166
+
167
+ return (
168
+ <div className={`relative w-full px-4 py-2 ${paddingDerecha}`} style={{ background: fondo, color: texto }}>
169
+ {contenido}
170
+ {anuncio.dismissible ? <BotonCerrar onClick={() => setCerrado(true)} /> : null}
171
+ </div>
172
+ )
173
+ }
@@ -0,0 +1,113 @@
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 { DrawerMovil, type DrawerMovilLogo } from "./drawer-movil"
6
+ import { mockCtx } from "../test/mock-ctx"
7
+
8
+ const logoTexto: DrawerMovilLogo = { modo: "texto", valor: "KINETIC", tamaño: 22 }
9
+
10
+ const menu = [
11
+ { texto: "Novedades", href: "/novedades" },
12
+ { texto: "Sale", href: "/sale" },
13
+ ]
14
+
15
+ const secciones = [
16
+ {
17
+ titulo: "Ropa",
18
+ enlaces: [
19
+ { texto: "Camisetas", href: "/ropa/camisetas" },
20
+ { texto: "Hoodies y buzos", href: "/ropa/hoodies" },
21
+ ],
22
+ },
23
+ {
24
+ titulo: "Zapatos",
25
+ enlaces: [{ texto: "Tenis", href: "/zapatos/tenis" }],
26
+ },
27
+ ]
28
+
29
+ const render = (props: Partial<Parameters<typeof DrawerMovil>[0]> = {}) =>
30
+ renderToStaticMarkup(
31
+ <DrawerMovil
32
+ abierto={true}
33
+ onCerrar={() => {}}
34
+ logo={logoTexto}
35
+ menu={menu}
36
+ secciones={secciones}
37
+ ctx={mockCtx()}
38
+ {...props}
39
+ />,
40
+ )
41
+
42
+ describe("DrawerMovil", () => {
43
+ it("abierto:false no renderiza nada", () => {
44
+ expect(render({ abierto: false })).toBe("")
45
+ })
46
+
47
+ it("es un diálogo modal con scrim y botón de cerrar accesible", () => {
48
+ const html = render()
49
+ expect(html).toContain('role="dialog"')
50
+ expect(html).toContain('aria-modal="true"')
51
+ expect(html).toContain("bg-black/50")
52
+ expect(html).toContain('aria-label="Cerrar menú"')
53
+ })
54
+
55
+ it("logo real modo texto pinta el wordmark (no el placeholder 'LOGO')", () => {
56
+ const html = render({ logo: { modo: "texto", valor: "KINETIC", tamaño: 22 } })
57
+ expect(html).toContain("KINETIC")
58
+ expect(html).not.toContain(">LOGO<")
59
+ })
60
+
61
+ it("logo real modo imagen resuelve via ctx.componentes.Imagen", () => {
62
+ const html = render({
63
+ logo: { modo: "imagen", valor: "https://ejemplo.com/logo.png", tamaño: 28 },
64
+ })
65
+ expect(html).toContain('src="https://ejemplo.com/logo.png"')
66
+ })
67
+
68
+ it("nunca emite el placeholder 'LOGO' hardcodeado en la fuente", () => {
69
+ const rutaFuente = fileURLToPath(new URL("./drawer-movil.tsx", import.meta.url))
70
+ const fuente = readFileSync(rutaFuente, "utf-8")
71
+ expect(fuente).not.toContain(">LOGO<")
72
+ expect(fuente).not.toMatch(/["']LOGO["']/)
73
+ })
74
+
75
+ it("pinta el nav: enlaces del menú y títulos de secciones", () => {
76
+ const html = render()
77
+ for (const item of menu) {
78
+ expect(html).toContain(item.texto)
79
+ expect(html).toContain(`href="${item.href}"`)
80
+ }
81
+ for (const seccion of secciones) {
82
+ expect(html).toContain(seccion.titulo)
83
+ }
84
+ })
85
+
86
+ it("invitado: muestra Iniciar sesión con enlace real de cuenta", () => {
87
+ const html = render()
88
+ expect(html).toContain("Iniciar sesión")
89
+ expect(html).toContain('href="/account"')
90
+ expect(html).toContain("Crea tu cuenta")
91
+ })
92
+
93
+ it("registrado: muestra saludo y enlaces reales de cuenta", () => {
94
+ const html = render({ ctx: mockCtx({ sesion: { autenticado: true, nombre: "Camila" } }) })
95
+ expect(html).toContain("Hola, Camila")
96
+ expect(html).toContain("Mis pedidos")
97
+ expect(html).toContain('href="/account/orders"')
98
+ expect(html).toContain("Cerrar sesión")
99
+ })
100
+
101
+ it("aplica la clase de animación de entrada (cms-drawer-panel) y respeta reduced-motion", () => {
102
+ const html = render()
103
+ expect(html).toContain("cms-drawer-panel")
104
+ expect(html).toContain("prefers-reduced-motion")
105
+ })
106
+
107
+ it("no emite <a> ni <img> directos: todo pasa por ctx.componentes", () => {
108
+ const rutaFuente = fileURLToPath(new URL("./drawer-movil.tsx", import.meta.url))
109
+ const fuente = readFileSync(rutaFuente, "utf-8")
110
+ expect(fuente).not.toMatch(/<a[\s>]/)
111
+ expect(fuente).not.toMatch(/<img[\s>]/)
112
+ })
113
+ })
@@ -0,0 +1,342 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import type { RenderContext } from "../context"
5
+
6
+ /**
7
+ * Drawer de navegación mobile — estética "Kinetic Street" (superficie, radio y
8
+ * sombra suaves, tokens `--cms-*` CON fallback). Lo abre el header (variante
9
+ * `minimal` y las barras mobile de inline/centrado/split).
10
+ *
11
+ * A11y básica: `role="dialog"` + `aria-modal`, cierre con Esc y con click en el
12
+ * scrim, foco inicial en el botón de cerrar y foco atrapado (Tab cicla dentro
13
+ * del panel). El body queda con scroll bloqueado mientras está abierto. La
14
+ * animación de entrada (slide + fade) respeta `prefers-reduced-motion`.
15
+ *
16
+ * `logo` es el logo REAL del chrome (texto o imagen), no un placeholder: se
17
+ * pinta con `ctx.componentes.Imagen` o como wordmark. Los enlaces de sesión y
18
+ * de cuenta usan `ctx.sesion` y las mismas rutas que el header (`/account`,
19
+ * `/favoritos`).
20
+ */
21
+
22
+ export type DrawerMovilItem = { texto: string; href: string }
23
+ export type DrawerMovilSeccion = { titulo: string; enlaces: DrawerMovilItem[] }
24
+ /** Logo del chrome; estructuralmente compatible con `HeaderProps["logo"]`. */
25
+ export type DrawerMovilLogo = { modo: "texto" | "imagen"; valor: string; tamaño?: number }
26
+
27
+ const claseBorde = "[border-bottom:var(--cms-grosor-borde,1px)_solid_var(--cms-borde,#dbd6c9)]"
28
+
29
+ const RUTAS = {
30
+ login: "/account",
31
+ registro: "/account",
32
+ pedidos: "/account/orders",
33
+ favoritos: "/favoritos",
34
+ cuenta: "/account",
35
+ logout: "/account/logout",
36
+ rastrear: "/ayuda/rastrear-pedido",
37
+ ayuda: "/ayuda/contacto",
38
+ }
39
+
40
+ const SELECTOR_FOCO =
41
+ 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
42
+
43
+ // ── Estilos de entrada (respeta prefers-reduced-motion) ──────────────────────
44
+
45
+ function EstiloDrawer() {
46
+ return (
47
+ <style
48
+ dangerouslySetInnerHTML={{
49
+ __html:
50
+ "@keyframes cms-drawer-in{from{transform:translateX(-100%)}to{transform:translateX(0)}}" +
51
+ "@keyframes cms-scrim-in{from{opacity:0}to{opacity:1}}" +
52
+ ".cms-drawer-panel{animation:cms-drawer-in .28s cubic-bezier(.22,1,.36,1)}" +
53
+ ".cms-drawer-scrim{animation:cms-scrim-in .28s ease-out}" +
54
+ "@media (prefers-reduced-motion: reduce){.cms-drawer-panel,.cms-drawer-scrim{animation:none}}",
55
+ }}
56
+ />
57
+ )
58
+ }
59
+
60
+ // ── Logo real ────────────────────────────────────────────────────────────────
61
+
62
+ function LogoDrawer({ logo, ctx }: { logo: DrawerMovilLogo; ctx: RenderContext }) {
63
+ const { Imagen, Enlace } = ctx.componentes
64
+ const alto = Math.max(12, logo.tamaño ?? 22)
65
+ return (
66
+ <Enlace href="/" className="inline-flex shrink-0 items-center">
67
+ {logo.modo === "imagen" && logo.valor ? (
68
+ <span className="inline-block" style={{ height: `${alto}px` }}>
69
+ <Imagen src={logo.valor} alt="Logo" className="h-full w-auto object-contain" />
70
+ </span>
71
+ ) : (
72
+ <span
73
+ className="leading-none"
74
+ style={{
75
+ fontSize: `${alto}px`,
76
+ fontFamily: "var(--cms-fuente-titulo)",
77
+ fontWeight: "var(--cms-titulo-peso,900)",
78
+ textTransform: "var(--cms-titulo-transform,uppercase)",
79
+ letterSpacing: "var(--cms-titulo-espaciado,-0.04em)",
80
+ color: "var(--cms-texto,#0c0c10)",
81
+ }}
82
+ >
83
+ {logo.valor || "Tienda"}
84
+ </span>
85
+ )}
86
+ </Enlace>
87
+ )
88
+ }
89
+
90
+ // ── Nav ────────────────────────────────────────────────────────────────────────
91
+
92
+ function ItemMenu({ item, ctx }: { item: DrawerMovilItem; ctx: RenderContext }) {
93
+ const { Enlace } = ctx.componentes
94
+ return (
95
+ <Enlace
96
+ href={item.href}
97
+ className={`flex items-center justify-between py-[15px] text-[15px] font-semibold ${claseBorde}`}
98
+ >
99
+ {item.texto}
100
+ </Enlace>
101
+ )
102
+ }
103
+
104
+ function Acordeon({
105
+ seccion,
106
+ abierta,
107
+ onToggle,
108
+ ctx,
109
+ }: {
110
+ seccion: DrawerMovilSeccion
111
+ abierta: boolean
112
+ onToggle: () => void
113
+ ctx: RenderContext
114
+ }) {
115
+ const { Enlace } = ctx.componentes
116
+ return (
117
+ <div className={claseBorde}>
118
+ <button
119
+ type="button"
120
+ onClick={onToggle}
121
+ aria-expanded={abierta}
122
+ className="flex w-full items-center justify-between bg-transparent py-[15px] text-[15px] font-semibold"
123
+ style={{ color: "var(--cms-texto,#0c0c10)" }}
124
+ >
125
+ {seccion.titulo}
126
+ <span className={`transition-transform ${abierta ? "rotate-90" : ""}`} aria-hidden>
127
+
128
+ </span>
129
+ </button>
130
+ {abierta ? (
131
+ <div
132
+ className="flex flex-col gap-2.5 pb-4 pl-3 text-sm"
133
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
134
+ >
135
+ {seccion.enlaces.map((enlace) => (
136
+ <Enlace key={enlace.href} href={enlace.href} className="block transition-opacity hover:opacity-70">
137
+ {enlace.texto}
138
+ </Enlace>
139
+ ))}
140
+ </div>
141
+ ) : null}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ // ── Sesión (enlaces reales) ──────────────────────────────────────────────────
147
+
148
+ function BloqueInvitado({ ctx }: { ctx: RenderContext }) {
149
+ const { Enlace } = ctx.componentes
150
+ return (
151
+ <>
152
+ <Enlace
153
+ href={RUTAS.login}
154
+ 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)]"
155
+ >
156
+ Iniciar sesión
157
+ </Enlace>
158
+ <div className="mt-2.5 text-center text-[12.5px]">
159
+ ¿Nuevo aquí?{" "}
160
+ <Enlace href={RUTAS.registro} className="font-semibold underline">
161
+ Crea tu cuenta
162
+ </Enlace>
163
+ </div>
164
+ <div
165
+ className="mt-4 flex justify-center gap-[18px] text-[11.5px]"
166
+ style={{ color: "var(--cms-texto-suave,#6b675e)" }}
167
+ >
168
+ <Enlace href={RUTAS.rastrear} className="hover:opacity-70">
169
+ Rastrear pedido
170
+ </Enlace>
171
+ <Enlace href={RUTAS.ayuda} className="hover:opacity-70">
172
+ Ayuda
173
+ </Enlace>
174
+ </div>
175
+ </>
176
+ )
177
+ }
178
+
179
+ function BloqueRegistrado({ nombre, ctx }: { nombre?: string; ctx: RenderContext }) {
180
+ const { Enlace } = ctx.componentes
181
+ return (
182
+ <div className="flex flex-col gap-3 text-sm">
183
+ <div className="text-sm font-semibold">Hola, {nombre}</div>
184
+ <div className="flex flex-col gap-3">
185
+ <Enlace href={RUTAS.pedidos} className="hover:opacity-70">
186
+ Mis pedidos
187
+ </Enlace>
188
+ <Enlace href={RUTAS.favoritos} className="hover:opacity-70">
189
+ Favoritos
190
+ </Enlace>
191
+ <Enlace href={RUTAS.cuenta} className="hover:opacity-70">
192
+ Mi cuenta
193
+ </Enlace>
194
+ <Enlace href={RUTAS.logout} className="hover:opacity-70" >
195
+ <span style={{ color: "var(--cms-texto-suave,#6b675e)" }}>Cerrar sesión</span>
196
+ </Enlace>
197
+ </div>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ // ── Icono de cierre ──────────────────────────────────────────────────────────
203
+
204
+ function IconoCerrar() {
205
+ return (
206
+ <svg
207
+ viewBox="0 0 24 24"
208
+ width="20"
209
+ height="20"
210
+ fill="none"
211
+ stroke="currentColor"
212
+ strokeWidth="1.8"
213
+ strokeLinecap="round"
214
+ aria-hidden="true"
215
+ focusable="false"
216
+ >
217
+ <path d="M6 6l12 12M18 6L6 18" />
218
+ </svg>
219
+ )
220
+ }
221
+
222
+ // ── Drawer ─────────────────────────────────────────────────────────────────────
223
+
224
+ export function DrawerMovil({
225
+ abierto,
226
+ onCerrar,
227
+ logo,
228
+ menu,
229
+ secciones,
230
+ ctx,
231
+ }: {
232
+ abierto: boolean
233
+ onCerrar: () => void
234
+ logo: DrawerMovilLogo
235
+ menu: DrawerMovilItem[]
236
+ secciones: DrawerMovilSeccion[]
237
+ ctx: RenderContext
238
+ }) {
239
+ const [seccionAbierta, setSeccionAbierta] = useState<string | null>(null)
240
+ const panelRef = useRef<HTMLDivElement>(null)
241
+ const cerrarRef = useRef<HTMLButtonElement>(null)
242
+
243
+ // Esc para cerrar + foco atrapado (Tab cicla) + scroll del body bloqueado.
244
+ useEffect(() => {
245
+ if (!abierto) return
246
+ cerrarRef.current?.focus()
247
+
248
+ const previo = document.body.style.overflow
249
+ document.body.style.overflow = "hidden"
250
+
251
+ const onKeyDown = (e: KeyboardEvent) => {
252
+ if (e.key === "Escape") {
253
+ onCerrar()
254
+ return
255
+ }
256
+ if (e.key !== "Tab") return
257
+ const panel = panelRef.current
258
+ if (!panel) return
259
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>(SELECTOR_FOCO)).filter(
260
+ (el) => el.offsetParent !== null || el === document.activeElement,
261
+ )
262
+ if (focusables.length === 0) return
263
+ const primero = focusables[0]
264
+ const ultimo = focusables[focusables.length - 1]
265
+ if (e.shiftKey && document.activeElement === primero) {
266
+ e.preventDefault()
267
+ ultimo.focus()
268
+ } else if (!e.shiftKey && document.activeElement === ultimo) {
269
+ e.preventDefault()
270
+ primero.focus()
271
+ }
272
+ }
273
+
274
+ document.addEventListener("keydown", onKeyDown)
275
+ return () => {
276
+ document.removeEventListener("keydown", onKeyDown)
277
+ document.body.style.overflow = previo
278
+ }
279
+ }, [abierto, onCerrar])
280
+
281
+ if (!abierto) return null
282
+
283
+ return (
284
+ <>
285
+ <EstiloDrawer />
286
+ <div className="cms-drawer-scrim fixed inset-0 z-40 bg-black/50" onClick={onCerrar} aria-hidden />
287
+ <div
288
+ ref={panelRef}
289
+ role="dialog"
290
+ aria-modal="true"
291
+ aria-label="Menú"
292
+ className="cms-drawer-panel fixed left-0 top-0 z-50 flex h-full w-[90%] max-w-[390px] flex-col"
293
+ style={{
294
+ background: "var(--cms-superficie,#fff)",
295
+ color: "var(--cms-texto,#0c0c10)",
296
+ boxShadow: "var(--cms-sombra-lg,0 30px 70px -28px rgba(12,12,16,.4))",
297
+ }}
298
+ >
299
+ <div className={`flex items-center justify-between px-4 py-4 ${claseBorde}`}>
300
+ <LogoDrawer logo={logo} ctx={ctx} />
301
+ <button
302
+ ref={cerrarRef}
303
+ type="button"
304
+ onClick={onCerrar}
305
+ aria-label="Cerrar menú"
306
+ className="inline-flex items-center justify-center leading-none transition-opacity hover:opacity-70"
307
+ >
308
+ <IconoCerrar />
309
+ </button>
310
+ </div>
311
+
312
+ <nav aria-label="Menú principal" className="flex flex-1 flex-col overflow-y-auto px-4">
313
+ {menu.map((item, i) => (
314
+ <ItemMenu key={`${item.href}-${i}`} item={item} ctx={ctx} />
315
+ ))}
316
+ {secciones.map((seccion) => (
317
+ <Acordeon
318
+ key={seccion.titulo}
319
+ seccion={seccion}
320
+ abierta={seccionAbierta === seccion.titulo}
321
+ onToggle={() =>
322
+ setSeccionAbierta((actual) => (actual === seccion.titulo ? null : seccion.titulo))
323
+ }
324
+ ctx={ctx}
325
+ />
326
+ ))}
327
+ </nav>
328
+
329
+ <div
330
+ className="mt-auto px-4 py-4"
331
+ style={{ borderTop: "var(--cms-grosor-borde,1px) solid var(--cms-borde,#dbd6c9)" }}
332
+ >
333
+ {ctx.sesion?.autenticado ? (
334
+ <BloqueRegistrado nombre={ctx.sesion.nombre} ctx={ctx} />
335
+ ) : (
336
+ <BloqueInvitado ctx={ctx} />
337
+ )}
338
+ </div>
339
+ </div>
340
+ </>
341
+ )
342
+ }