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