{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "sidebar",
"type": "registry:block",
"title": "sidebar",
"description": "Bloque Concorde sidebar — incluye sus 5 componentes.",
"dependencies": [
"react"
],
"registryDependencies": [],
"files": [
{
"path": "bloques/sidebar/desktop/dimensions.ts",
"type": "registry:file",
"target": "bloques/sidebar/desktop/dimensions.ts",
"content": "/**\n * Dimensiones del bloque Sidebar — en un módulo PLANO (sin \"use client\") para\n * que los Server Components (catálogo /blocks, páginas de bloque) puedan importar\n * estas constantes. Importar valores desde un módulo \"use client\" hacia un Server\n * Component devuelve undefined → NaN en los cálculos de escala.\n */\n\nexport const SIDEBAR_WIDTH = 226;\nexport const SIDEBAR_HEIGHT = 1171;\nexport const SIDEBAR_COLLAPSED_WIDTH = 76; // solo iconos\n"
},
{
"path": "bloques/sidebar/desktop/Sidebar.tsx",
"type": "registry:file",
"target": "bloques/sidebar/desktop/Sidebar.tsx",
"content": "\"use client\";\n\n/**\n * Sidebar — Bloque de navegación lateral (Voyager DS)\n * Fuente: Figma VOYAGER · \"SideBarDefault\" (226×1042)\n *\n * Estructura:\n * ┌──────────────────────────────┐ ← y=0\n * │ SidebarHeader (60px) │\n * ├──────────────────────────────┤ ← y=60 ← borde VYStrokes1\n * │ Nav primary items (4) │\n * │ ─── separator ─── │\n * │ Nav secondary items (1) │\n * │ │\n * │ ┌──────────────────────┐ │\n * │ │ SidebarBanner 200×264│ │\n * │ └──────────────────────┘ │\n * └──────────────────────────────┘ ← y=1042\n */\n\nimport { useState } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport SidebarHeader from \"./SidebarHeader\";\nimport SidebarBanner from \"./SidebarBanner\";\nimport SidebarItem, { SidebarSeparator, type SidebarNavItem } from \"./SidebarItem\";\nimport TodayIcon from \"@/concorde/components/TodayIcon\";\nimport OfferIcon from \"@/concorde/components/OfferIcon\";\nimport CategoryIcon from \"@/concorde/components/CategoryIcon\";\nimport BusinessIcon from \"@/concorde/components/BusinessIcon\";\nimport ServiceCenterIcon from \"@/concorde/components/ServiceCenterIcon\";\n\n// Dimensiones en un módulo plano (no-client) para que los Server Components puedan\n// importarlas sin obtener undefined. Se re-exportan para los consumidores cliente.\nexport { SIDEBAR_WIDTH, SIDEBAR_HEIGHT, SIDEBAR_COLLAPSED_WIDTH } from \"./dimensions\";\nimport { SIDEBAR_WIDTH, SIDEBAR_HEIGHT, SIDEBAR_COLLAPSED_WIDTH } from \"./dimensions\";\n\nconst EASE = \"cubic-bezier(0.4,0,0.2,1)\";\n\nconst DEFAULT_PRIMARY: SidebarNavItem[] = [\n { id: \"hoy\", icon: (s) => , label: \"Hoy\" },\n {\n id: \"oferta\", icon: (s) => , label: \"Tipo de oferta\", count: 2,\n subitems: [\n { id: \"en-vivo\", label: \"En Vivo\", count: 38 },\n { id: \"negociable\", label: \"Negociable\" },\n ],\n },\n { id: \"categoria\", icon: (s) => , label: \"Categoría\", count: 2 },\n { id: \"empresas\", icon: (s) => , label: \"Empresas\", count: 2 },\n];\n\nconst DEFAULT_SECONDARY: SidebarNavItem[] = [\n { id: \"centro\", icon: (s) => , label: \"Centro de Ayuda\" },\n];\n\nconst STYLE_ID = \"concorde-sidebar-styles\";\nconst STYLES = `\n.sb-root {\n transition: width 0.28s ${EASE}, height 0.28s ${EASE};\n}\n.sb-content-frame {\n border: 1.5px solid transparent;\n /* Borde VYStrokes1 — dirección exacta del SVG (paint20: vector 283.9,21.1 → 94deg) */\n background:\n linear-gradient(#2E0F70, #2E0F70) padding-box,\n linear-gradient(94deg, #FFFFFF 0%, #F4AC59 22.1%, #8460E5 74.5%, #FFFFFF 100%) border-box;\n transition: width 0.28s ${EASE};\n}\n.sb-banner {\n display: flex;\n justify-content: center;\n padding: 0 7px 4px;\n opacity: 1;\n max-height: 280px;\n overflow: hidden;\n transition: opacity 0.18s ease, max-height 0.28s ${EASE}, padding 0.28s ${EASE};\n}\n.sb-banner--collapsed {\n opacity: 0;\n max-height: 0;\n padding: 0;\n pointer-events: none;\n}\n@media (prefers-reduced-motion: reduce) {\n .sb-root, .sb-content-frame, .sb-banner { transition: none; }\n}\n`;\n\nlet _injected = false;\n\nexport type { SidebarNavItem };\n\nexport interface SidebarProps {\n logo?: ReactNode;\n primaryItems?: SidebarNavItem[];\n secondaryItems?: SidebarNavItem[];\n defaultActiveId?: string;\n /** Alto del sidebar. Por defecto SIDEBAR_HEIGHT; pásale el alto del canvas\n * cuando el contenido a la derecha sea más alto, para que crezca con él. */\n height?: number | string;\n /** Alto del bloque interno fijo (header + nav + banner). Por defecto\n * SIDEBAR_HEIGHT (igual que siempre: si `height` es mayor, el sobrante\n * queda como relleno morado plano debajo, sin estirar nav/banner).\n * Edítalo SOLO cuando el contenido de al lado sea más CORTO que el\n * sidebar — pásale ese mismo alto (igual a `height`) para que el spacer\n * interno se comprima y el sidebar no sobre por debajo del contenido. */\n contentHeight?: number;\n /** Alto de la cabecera fija (logo + hamburguesa), en px. Por defecto 60. Cuando\n * el contenido de al lado tiene su propio header y se escala, pásale la altura\n * de ESE header escalada (p.ej. HEADER_HEIGHT × scale) para que el top de los\n * iconos del sidebar quede alineado con el top del contenido. */\n headerHeight?: number;\n /** Colapsado (controlado). Si se omite, el Sidebar maneja su estado interno. */\n collapsed?: boolean;\n /** Callback al alternar el colapsado (para reaccionar desde el contenedor). */\n onCollapsedChange?: (collapsed: boolean) => void;\n onItemClick?: (id: string) => void;\n onBannerCta?: () => void;\n}\n\nexport default function Sidebar({\n logo,\n primaryItems = DEFAULT_PRIMARY,\n secondaryItems = DEFAULT_SECONDARY,\n defaultActiveId,\n height = SIDEBAR_HEIGHT,\n contentHeight = SIDEBAR_HEIGHT,\n headerHeight = 60,\n collapsed: collapsedProp,\n onCollapsedChange,\n onItemClick,\n onBannerCta,\n}: SidebarProps): JSX.Element {\n const [internalCollapsed, setInternalCollapsed] = useState(false);\n const collapsed = collapsedProp ?? internalCollapsed;\n const [activeId, setActiveId] = useState(defaultActiveId);\n\n function toggleCollapsed(): void {\n const next = !collapsed;\n if (collapsedProp === undefined) setInternalCollapsed(next);\n onCollapsedChange?.(next);\n }\n\n if (typeof document !== \"undefined\" && !_injected) {\n if (!document.getElementById(STYLE_ID)) {\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLES;\n document.head.appendChild(el);\n }\n _injected = true;\n }\n\n function handleItemClick(id: string) {\n setActiveId(id);\n onItemClick?.(id);\n }\n\n return (\n <>\n \n
\n {/* Cabecera FIJA (logo + hamburguesa) — ancho completo 226, no colapsa.\n Va como overlay en el top; el bloque de contenido de abajo deja headerHeight px. */}\n
\n \n
\n\n {/* Bloque colapsable (nav + banner) — este SÍ encoge su ancho. Recortado\n con overflow:hidden y desplazado headerHeight px para no tapar la cabecera. */}\n
\n {/* Área de contenido 216×935 — borde VYStrokes1 */}\n
\n >\n );\n}\n"
},
{
"path": "bloques/sidebar/mobile/dimensions.ts",
"type": "registry:file",
"target": "bloques/sidebar/mobile/dimensions.ts",
"content": "/**\n * Dimensiones del bloque SidebarMobile — módulo plano (sin \"use client\") para que\n * los Server Components puedan importarlas sin obtener `undefined`.\n */\n\n/** Ancho del viewport móvil de referencia. */\nexport const SIDEBAR_MOBILE_WIDTH = 420;\n/** Alto del viewport móvil de referencia. */\nexport const SIDEBAR_MOBILE_HEIGHT = 780;\n/** Ancho del panel deslizante (igual que el Sidebar de escritorio). */\nexport const SIDEBAR_DRAWER_WIDTH = 226;\n"
},
{
"path": "bloques/sidebar/mobile/SidebarMobile.tsx",
"type": "registry:file",
"target": "bloques/sidebar/mobile/SidebarMobile.tsx",
"content": "\"use client\";\n\n/**\n * SidebarMobile — variante móvil del bloque Sidebar (Voyager DS).\n *\n * Reutiliza el mismo `Sidebar` de escritorio (idénticos colores, glass, gradientes\n * y estados) y lo presenta como un drawer: se desliza desde la izquierda por encima\n * del contenido y oscurece el fondo con un backdrop, en lugar de empujar el layout.\n *\n * @example\n * ```tsx\n * const [open, setOpen] = useState(false);\n * \n * {appContent}\n * \n * ```\n *\n * El botón que abre el drawer se coloca en el header de la app y se cablea a\n * `onOpenChange(true)` (o usa `showTrigger` para el trigger flotante integrado).\n * Cierra con: backdrop, tecla Escape, o el botón del propio Sidebar.\n */\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\nimport Sidebar, { type SidebarNavItem } from \"../desktop/Sidebar\";\nimport { SIDEBAR_DRAWER_WIDTH } from \"./dimensions\";\n\nexport { SIDEBAR_MOBILE_WIDTH, SIDEBAR_MOBILE_HEIGHT, SIDEBAR_DRAWER_WIDTH } from \"./dimensions\";\nexport type { SidebarNavItem };\n\nconst EASE = \"cubic-bezier(0.4,0,0.2,1)\";\n\nconst STYLE_ID = \"concorde-sidebar-mobile-styles\";\nconst STYLES = `\n.sbm-root { position: relative; }\n\n/* Marco opcional para previews: alto fijo con scroll interno; su transform crea el\n containing block que ancla el drawer fixed dentro del marco (ver prop frameHeight). */\n.sbm-frame {\n position: relative;\n height: 100%;\n overflow-y: auto;\n overflow-x: hidden;\n transform: translateZ(0);\n scrollbar-width: none;\n -ms-overflow-style: none;\n}\n.sbm-frame::-webkit-scrollbar { width: 0; height: 0; display: none; }\n.sbm-frame--locked { overflow: hidden; }\n\n.sbm-trigger {\n position: absolute;\n top: 16px;\n left: 16px;\n z-index: 7;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 44px;\n height: 44px;\n padding: 0;\n border: 1px solid rgba(255,255,255,0.12);\n border-radius: 12px;\n background: #2E0F70;\n box-shadow: 0 6px 18px -6px rgba(10,0,46,0.6);\n cursor: pointer;\n transition: transform 0.15s, background 0.15s, opacity 0.2s;\n}\n.sbm-trigger:hover { background: #3a1a86; }\n.sbm-trigger:active { transform: scale(0.94); }\n.sbm-trigger:focus-visible { outline: 2px solid #8460E5; outline-offset: 2px; }\n\n.sbm-backdrop {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(10,0,38,0.55);\n -webkit-backdrop-filter: blur(2px);\n backdrop-filter: blur(2px);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.28s ${EASE};\n}\n.sbm-backdrop--open { opacity: 1; pointer-events: auto; }\n\n.sbm-panel {\n position: fixed;\n top: 0;\n left: 0;\n bottom: 0;\n z-index: 41;\n width: ${SIDEBAR_DRAWER_WIDTH}px;\n max-width: 86%;\n transform: translateX(-100%);\n transition: transform 0.3s ${EASE};\n box-shadow: 0 0 40px -4px rgba(10,0,46,0.6);\n will-change: transform;\n}\n.sbm-panel--open { transform: translateX(0); }\n.sbm-panel-inner { height: 100%; }\n.sbm-panel-inner > [data-slot=\"sidebar\"] { height: 100% !important; }\n\n@media (prefers-reduced-motion: reduce) {\n .sbm-trigger, .sbm-backdrop, .sbm-panel { transition: none; }\n}\n`;\n\nlet _injected = false;\n\nexport interface SidebarMobileProps {\n /** Contenido de la aplicación. Se renderiza detrás del drawer y se oscurece al abrir. */\n children?: ReactNode;\n /** Logo mostrado en la cabecera del Sidebar. */\n logo?: ReactNode;\n /** Ítems de navegación principales. Por defecto usa los del Sidebar. */\n primaryItems?: SidebarNavItem[];\n /** Ítems de navegación secundarios (bajo el separador). */\n secondaryItems?: SidebarNavItem[];\n /** Id del ítem activo inicial. */\n defaultActiveId?: string;\n /** Estado abierto (modo controlado). Si se omite, el drawer gestiona su estado. */\n open?: boolean;\n /** Se invoca al abrir o cerrar el drawer. */\n onOpenChange?: (open: boolean) => void;\n /** Se invoca con el id del ítem seleccionado. */\n onItemClick?: (id: string) => void;\n /** Se invoca al pulsar el CTA del banner inferior. */\n onBannerCta?: () => void;\n /**\n * Cierra el drawer al seleccionar un ítem. Por defecto `false`: seleccionar un\n * ítem solo expande sus subítems. Actívalo cuando los ítems naveguen a una ruta.\n */\n closeOnItemClick?: boolean;\n /**\n * Muestra un botón hamburguesa flotante que abre el drawer. Por defecto `false`:\n * lo habitual es colocar el botón en la cabecera de la app y llamar a\n * `onOpenChange(true)`.\n */\n showTrigger?: boolean;\n /**\n * Alto del bloque interno del Sidebar (cabecera + navegación + banner), en px.\n * Debe cubrir el alto visible para que el banner no se recorte. Por defecto `780`.\n */\n drawerContentHeight?: number;\n /**\n * Envuelve el contenido en un marco de alto fijo (px) con scroll interno,\n * confinando el drawer a ese marco. Pensado para previsualizaciones embebidas;\n * en producción se omite y el drawer flota sobre el viewport completo.\n */\n frameHeight?: number;\n}\n\nexport default function SidebarMobile({\n children,\n logo,\n primaryItems,\n secondaryItems,\n defaultActiveId,\n open: openProp,\n onOpenChange,\n onItemClick,\n onBannerCta,\n closeOnItemClick = false,\n showTrigger = false,\n drawerContentHeight = 780,\n frameHeight,\n}: SidebarMobileProps): JSX.Element {\n const rootRef = useRef(null);\n const [internalOpen, setInternalOpen] = useState(false);\n const open = openProp ?? internalOpen;\n\n function setOpen(next: boolean): void {\n if (openProp === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n }\n\n if (typeof document !== \"undefined\" && !_injected) {\n if (!document.getElementById(STYLE_ID)) {\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = STYLES;\n document.head.appendChild(el);\n }\n _injected = true;\n }\n\n useEffect(\n function closeOnEscape() {\n if (!open) return;\n function onKey(e: KeyboardEvent): void {\n if (e.key === \"Escape\") setOpen(false);\n }\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [open],\n );\n\n useEffect(\n function lockScroll() {\n if (!open || typeof document === \"undefined\") return;\n // Con frame, el scroll vive en el marco; sin frame, en el documento. En el\n // marco reseteamos scrollTop a 0 para que el drawer fixed cubra todo lo visible.\n const frame = frameHeight ? rootRef.current : null;\n const prevBodyOverflow = document.body.style.overflow;\n let prevScrollTop = 0;\n if (frame) {\n prevScrollTop = frame.scrollTop;\n frame.scrollTop = 0;\n } else {\n document.body.style.overflow = \"hidden\";\n }\n return () => {\n if (frame) frame.scrollTop = prevScrollTop;\n else document.body.style.overflow = prevBodyOverflow;\n };\n },\n [open, frameHeight],\n );\n\n function handleItemClick(id: string): void {\n onItemClick?.(id);\n if (closeOnItemClick) setOpen(false);\n }\n\n return (\n <>\n \n