{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "cardviewer", "type": "registry:component", "title": "CardViewer", "description": "Componente Concorde CardViewer — un solo archivo, CardViewer.tsx.", "dependencies": [ "react" ], "registryDependencies": [], "files": [ { "path": "components/CardViewer.tsx", "type": "registry:file", "target": "components/CardViewer.tsx", "content": "\"use client\";\n\n/**\n * CardViewer — Generado por Concorde\n * Fuente: Figma VOYAGER · \"ImageViewer\" (2007:16993) + \"Filmstrip\" (2007:16994 / 2007:18456)\n *\n * Visor de imagen 443×362 (controles glass: expandir · ‹ · › · contador) CON\n * su tira de miniaturas (Filmstrip 443×87, drag-to-scroll) debajo, como UNA sola\n * unidad. Click en una miniatura → cambia la imagen grande al instante.\n *\n * 2 variantes (por el borde de la miniatura seleccionada):\n * · live → white → #F4AC59 → #8460E5 → white\n * · negotiable → white → #4DDCDC → #6445DF → white\n *\n * Estado interno (no controlado) por defecto; o controlado vía selectedIndex.\n */\n\nimport { useId, useRef, useState } from \"react\";\nimport type { JSX, ReactNode, MouseEventHandler, PointerEvent as ReactPointerEvent } from \"react\";\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type CardViewerVariant = \"live\" | \"negotiable\";\n\nexport interface CardViewerProps {\n /** Variante del borde de selección (default \"live\") */\n variant?: CardViewerVariant;\n /** URLs de las imágenes (visor + miniaturas) */\n images?: string[];\n /** Alt de la imagen grande */\n imageAlt?: string;\n /** Índice seleccionado (controlado, 0-based) */\n selectedIndex?: number;\n /** Índice inicial (no controlado, default 0) */\n defaultIndex?: number;\n /** Callback al cambiar de imagen (click en miniatura o ‹/›) */\n onSelect?: (index: number) => void;\n /** Click en el botón expandir */\n onExpand?: MouseEventHandler;\n /** Embebido dentro de una card padre: el visor va a ras (sin sombra ni\n * redondeo propios), porque el borde/redondeo los pone el contenedor. */\n embedded?: boolean;\n /** Slot opcional ARRIBA del visor, DENTRO de la card. Cuando se pasa, un solo\n * borde gradiente + redondeo inferior envuelve header+visor (el filmstrip\n * queda fuera, debajo). Úsalo para el conjunto SalaStatus + visor. */\n header?: ReactNode;\n className?: string;\n}\n\n// ─── Self-contained CSS ───────────────────────────────────────────────────────\n\nconst STYLE_ID = \"concorde-cardviewer-styles\";\n\nconst CARDVIEWER_STYLES = `\n.pcardv {\n display: flex;\n flex-direction: column;\n gap: 9px;\n width: 443px;\n max-width: 100%;\n font-family: var(--vmc-font-display, \"Plus Jakarta Sans\", -apple-system, sans-serif);\n}\n\n/* ── Visor de imagen ── */\n.pcardv__viewer {\n position: relative;\n width: 100%;\n height: 362px;\n border-radius: 0 0 16px 16px;\n overflow: hidden;\n background: #F2F4F3;\n box-shadow: rgba(0,0,0,0.1) 0px 0px 16px 4px;\n}\n.pcardv__img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.pcardv__btn {\n position: absolute;\n appearance: none;\n border: none;\n background: rgba(0,0,0,0.5);\n -webkit-backdrop-filter: blur(2px);\n backdrop-filter: blur(2px);\n color: #ffffff;\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n transition: background 0.18s, transform 0.18s;\n}\n.pcardv__btn:hover { background: rgba(0,0,0,0.65); }\n.pcardv__btn:active { transform: scale(0.92); }\n.pcardv__btn:focus-visible { outline: 2px solid #ffffff; outline-offset: 2px; }\n.pcardv__btn--round { width: 36px; height: 36px; border-radius: 50%; }\n.pcardv__expand { top: 12px; right: 12px; }\n.pcardv__prev { top: 50%; left: 12px; transform: translateY(-50%); }\n.pcardv__next { top: 50%; right: 12px; transform: translateY(-50%); }\n.pcardv__prev:active, .pcardv__next:active { transform: translateY(-50%) scale(0.92); }\n.pcardv__count {\n position: absolute;\n bottom: 12px;\n right: 12px;\n height: 24px;\n min-width: 46px;\n padding: 0 10px;\n border-radius: 12px;\n background: rgba(0,0,0,0.5);\n -webkit-backdrop-filter: blur(2px);\n backdrop-filter: blur(2px);\n color: #ffffff;\n font-size: 12px;\n font-weight: 600;\n letter-spacing: 0.02em;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* ── Tira de miniaturas (filmstrip) ── */\n.pcardv__strip {\n display: flex;\n gap: 9px;\n width: 100%;\n overflow-x: auto;\n padding: 3px;\n box-sizing: border-box;\n scrollbar-width: none;\n cursor: grab;\n scroll-behavior: smooth;\n}\n.pcardv__strip--dragging { cursor: grabbing; scroll-behavior: auto; }\n.pcardv__strip--dragging .pcardv__thumb { pointer-events: none; }\n.pcardv__strip::-webkit-scrollbar { display: none; }\n.pcardv__thumb {\n position: relative;\n flex-shrink: 0;\n width: 113px;\n height: 84px;\n border-radius: 4px;\n padding: 0;\n border: none;\n background: transparent;\n cursor: pointer;\n overflow: hidden;\n transition: transform 0.18s, box-shadow 0.18s;\n}\n.pcardv__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 4px; }\n.pcardv__thumb:focus-visible { outline: 2px solid #8460E5; outline-offset: 2px; }\n/* Hover preview ring · indica click sin transform (igual al filmstrip de detail-header) */\n.pcardv__thumb::before {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: 4px;\n pointer-events: none;\n z-index: 2;\n box-shadow: none;\n transition: box-shadow 200ms cubic-bezier(0.3, 0, 0, 1);\n}\n.pcardv__thumb:not(.pcardv__thumb--selected):hover::before {\n box-shadow:\n inset 0 0 0 2px oklch(0.66 0.17 50 / 0.45),\n inset 0 0 0 3px oklch(1 0 0 / 0.30);\n}\n.pcardv--negotiable .pcardv__thumb:not(.pcardv__thumb--selected):hover::before {\n box-shadow:\n inset 0 0 0 2px oklch(0.78 0.14 195 / 0.45),\n inset 0 0 0 3px oklch(1 0 0 / 0.30);\n}\n/* Selección = aro gradiente de 3px ENCIMA (no cambia el tamaño de la miniatura) */\n.pcardv__thumb--selected { box-shadow: rgba(0,0,0,0.1) 0px 0px 16px 4px; }\n.pcardv__thumb--selected::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: 4px;\n padding: 3px;\n -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);\n -webkit-mask-composite: xor;\n mask-composite: exclude;\n pointer-events: none;\n}\n.pcardv--live .pcardv__thumb--selected::after {\n background: linear-gradient(120deg, #ffffff 0%, #F4AC59 25%, #8460E5 75%, #ffffff 100%);\n}\n.pcardv--negotiable .pcardv__thumb--selected::after {\n background: linear-gradient(120deg, #ffffff 0%, #4DDCDC 25%, #6445DF 75%, #ffffff 100%);\n}\n\n/* ── Embebido en una card padre: visor a ras (el borde/redondeo lo pone el padre) ── */\n.pcardv--embedded .pcardv__viewer { border-radius: 0; box-shadow: none; }\n\n/* ── Card del conjunto (header + visor) · UN solo borde gradiente, top recto, bottom 16 ── */\n.pcardv__card {\n box-sizing: border-box;\n width: 100%;\n padding: 1.5px;\n border-radius: 0 0 16px 16px;\n background: linear-gradient(120deg, #ffffff 0%, #F4AC59 22%, #8460E5 74.5%, #ffffff 100%);\n box-shadow: rgba(0,0,0,0.15) 0px 8px 20px;\n}\n.pcardv__card-inner {\n box-sizing: border-box;\n width: 100%;\n border-radius: 0 0 15px 15px;\n overflow: hidden;\n background: #2E0F70;\n}\n/* Visor a ras dentro de la card (el borde/redondeo los pone la card) */\n.pcardv__card .pcardv__viewer { border-radius: 0; box-shadow: none; }\n\n@media (prefers-reduced-motion: reduce) {\n .pcardv__btn, .pcardv__thumb, .pcardv__thumb::before { transition: none; }\n}\n`;\n\nlet _stylesInjected = false;\n\nconst DEFAULT_IMAGES = [\"\", \"\", \"\", \"\"];\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\nexport default function CardViewer({\n variant = \"live\",\n images = DEFAULT_IMAGES,\n imageAlt = \"\",\n selectedIndex,\n defaultIndex = 0,\n onSelect,\n onExpand,\n embedded = false,\n header,\n className = \"\",\n}: CardViewerProps): JSX.Element {\n const uid = useId().replace(/:/g, \"-\");\n const trackRef = useRef(null);\n const drag = useRef({ active: false, startX: 0, startScroll: 0, moved: false });\n\n const isControlled = selectedIndex !== undefined;\n const [internalIndex, setInternalIndex] = useState(defaultIndex);\n const total = images.length;\n const index = isControlled ? selectedIndex : internalIndex;\n\n function select(target: number): void {\n const next = total > 0 ? ((target % total) + total) % total : 0;\n if (!isControlled) setInternalIndex(next);\n onSelect?.(next);\n }\n\n // Solo registramos el inicio; NO capturamos el puntero todavía para que un\n // click simple llegue normal al botón de la miniatura.\n const onPointerDown = (e: ReactPointerEvent): void => {\n const el = trackRef.current;\n if (!el) return;\n drag.current = { active: true, startX: e.clientX, startScroll: el.scrollLeft, moved: false };\n };\n\n const onPointerMove = (e: ReactPointerEvent): void => {\n const el = trackRef.current;\n if (!el || !drag.current.active) return;\n const dx = e.clientX - drag.current.startX;\n // Recién al superar 4px se considera arrastre: capturamos el puntero y\n // desactivamos los clicks de las miniaturas mientras se arrastra.\n if (!drag.current.moved && Math.abs(dx) > 4) {\n drag.current.moved = true;\n el.classList.add(\"pcardv__strip--dragging\");\n try { el.setPointerCapture(e.pointerId); } catch { /* noop */ }\n }\n if (drag.current.moved) {\n el.scrollLeft = drag.current.startScroll - dx;\n }\n };\n\n const endDrag = (e: ReactPointerEvent): void => {\n const el = trackRef.current;\n if (!el) return;\n drag.current.active = false;\n el.classList.remove(\"pcardv__strip--dragging\");\n try { el.releasePointerCapture(e.pointerId); } catch { /* noop */ }\n };\n\n if (typeof document !== \"undefined\" && !_stylesInjected) {\n if (!document.getElementById(STYLE_ID)) {\n const el = document.createElement(\"style\");\n el.id = STYLE_ID;\n el.textContent = CARDVIEWER_STYLES;\n document.head.appendChild(el);\n }\n _stylesInjected = true;\n }\n\n const currentSrc = images[index];\n\n const viewer = (\n
\n {currentSrc ? {imageAlt} : null}\n\n \n\n \n\n \n\n {index + 1}/{total}\n
\n );\n\n return (\n <>\n