{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "amountoptiongroup", "type": "registry:component", "title": "AmountOptionGroup", "description": "Componente Concorde AmountOptionGroup — un solo archivo, AmountOptionGroup.tsx.", "dependencies": [ "react" ], "registryDependencies": [], "files": [ { "path": "components/AmountOptionGroup.tsx", "type": "registry:file", "target": "components/AmountOptionGroup.tsx", "content": "\"use client\";\n\n/**\n * AmountOptionGroup — Generado por Concorde\n *\n * Selector de monto configurable, compuesto con AmountOption:\n * · `amounts` → cuántos montos fijos (uno por elemento)\n * · `allowCustom` → si se muestra la opción personalizada (input editable)\n * Maneja la selección tipo radio (uno activo a la vez). Controlado o no.\n */\n\nimport { useId, useState } from \"react\";\nimport type { JSX } from \"react\";\nimport AmountOption from \"@/concorde/components/AmountOption\";\n\nexport type AmountSelection = number | \"custom\";\n\nexport interface AmountOptionGroupProps {\n /** Montos fijos (solo el número) — la cantidad = length. P.ej. [\"80\",\"130\",\"210\"] */\n amounts: string[];\n /** Muestra la opción personalizada (input editable) — default false */\n allowCustom?: boolean;\n /** Etiqueta sobre la opción personalizada — default \"Ingresa un monto:\" */\n customLabel?: string;\n /** Placeholder del número del input — default \"0\" */\n customPlaceholder?: string;\n /** Prefijo del monto — default \">S<\" */\n prefix?: string;\n /** Selección controlada: índice del monto fijo o \"custom\" */\n value?: AmountSelection;\n /** Selección inicial (no controlado) — default 0 */\n defaultValue?: AmountSelection;\n onChange?: (selected: AmountSelection) => void;\n /** Valor del input personalizado (controlado) */\n customValue?: string;\n onCustomValueChange?: (value: string) => void;\n /** name del grupo radio */\n name?: string;\n className?: string;\n}\n\nexport default function AmountOptionGroup({\n amounts,\n allowCustom = false,\n customLabel = \"Ingresa un monto:\",\n customPlaceholder = \"0\",\n prefix = \">S<\",\n value,\n defaultValue = 0,\n onChange,\n customValue,\n onCustomValueChange,\n name,\n className = \"\",\n}: AmountOptionGroupProps): JSX.Element {\n const uid = useId().replace(/:/g, \"-\");\n const controlled = value !== undefined;\n const [internalSel, setInternalSel] = useState(defaultValue);\n const sel = controlled ? value : internalSel;\n\n const customControlled = customValue !== undefined;\n const [internalCustom, setInternalCustom] = useState(\"\");\n const cval = customControlled ? customValue : internalCustom;\n\n const groupName = name ?? `amountgroup-${uid}`;\n\n function select(next: AmountSelection): void {\n if (!controlled) setInternalSel(next);\n onChange?.(next);\n }\n\n function handleCustomChange(v: string): void {\n if (!customControlled) setInternalCustom(v);\n onCustomValueChange?.(v);\n select(\"custom\");\n }\n\n return (\n
\n {amounts.map(function renderFixed(a, i) {\n return (\n \n );\n })}\n\n {allowCustom ? (\n \n \n {customLabel}\n \n \n
\n ) : null}\n \n );\n}\n" }, { "path": "components/AmountOption.tsx", "type": "registry:file", "target": "components/AmountOption.tsx", "content": "\"use client\";\n\n/**\n * AmountOption — Generado por Concorde\n * Fuente: Figma VOYAGER · \"AmountOption\" (3032:13035 default · 13029 selected · 13044 input)\n *\n * Opción de monto tipo radio (pill 254×48, radio 24) con un círculo a la\n * izquierda y el monto a la derecha. 3 variantes:\n * · default → blanco, borde gradiente sutil, radio gris, monto morado #3B1782\n * · selected → relleno gradiente morado (#8460E5→#3B1782), borde gradiente,\n * radio relleno blanco, monto blanco\n * · input → blanco, radio gris, campo editable con placeholder gris #D1D5DC\n * Sombra 0 0 16px 4px rgba(0,0,0,0.1). Controlado o no controlado.\n */\n\nimport { useId } from \"react\";\nimport type { JSX, ReactNode } from \"react\";\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type AmountOptionVariant = \"default\" | \"selected\" | \"input\";\n\nexport interface AmountOptionProps {\n /** Apariencia/estado (default \"default\") */\n variant?: AmountOptionVariant;\n /** Monto fijo (modo default/selected) — p.ej. \">S< 80\" */\n amount?: ReactNode;\n /** Valor del campo (modo input, controlado) */\n value?: string;\n /** Valor inicial (modo input, no controlado) */\n defaultValue?: string;\n /** Prefijo fijo del campo (modo input) — siempre visible (default \">S<\") */\n prefix?: string;\n /** Placeholder del número (modo input) — p.ej. \"210\" */\n placeholder?: string;\n onValueChange?: (value: string) => void;\n /** Click en la opción (modo default/selected) */\n onSelect?: () => void;\n /** Marca la opción input como seleccionada (morada) aunque no tenga foco */\n selected?: boolean;\n disabled?: boolean;\n /** name del radio (para agrupar) */\n name?: string;\n \"aria-label\"?: string;\n className?: string;\n}\n\n// ─── Self-contained CSS ───────────────────────────────────────────────────────\n\nconst STYLE_ID = \"concorde-amountoption-styles\";\n\nconst AMOUNTOPTION_STYLES = `\n.pamount {\n box-sizing: border-box;\n display: flex;\n align-items: center;\n gap: 12px;\n width: 254px;\n max-width: 100%;\n height: 48px;\n padding: 0 16px;\n border-radius: 24px;\n border: 1px solid transparent;\n background-image: linear-gradient(#ffffff, #ffffff), linear-gradient(335deg, #8460E5 0%, #FFF8F1 100%);\n background-origin: border-box;\n background-clip: padding-box, border-box;\n box-shadow: rgba(0,0,0,0.1) 0px 0px 16px 4px;\n cursor: pointer;\n font-family: var(--vmc-font-display, \"Plus Jakarta Sans\", -apple-system, sans-serif);\n text-align: left;\n transition: transform 0.15s, box-shadow 0.2s;\n}\n.pamount:focus-visible { outline: 2px solid #8460E5; outline-offset: 2px; }\n.pamount:active { transform: scale(0.99); }\n.pamount__radio {\n flex-shrink: 0;\n width: 18px;\n height: 18px;\n border-radius: 50%;\n border: 1px solid #E1E3E2;\n background: transparent;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.pamount__dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #ffffff;\n opacity: 0;\n}\n.pamount__amount {\n flex: 1;\n min-width: 0;\n text-align: right;\n white-space: nowrap;\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: #3B1782;\n}\n.pamount__amountwrap {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 6px;\n}\n.pamount__prefix {\n flex-shrink: 0;\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: #D1D5DC;\n}\n.pamount__num {\n width: auto;\n min-width: 1ch;\n border: none;\n outline: none;\n background: transparent;\n text-align: right;\n font-family: inherit;\n font-size: 16px;\n font-weight: 700;\n letter-spacing: 0.02em;\n color: #3B1782;\n}\n.pamount__num::placeholder { color: #D1D5DC; }\n\n/* input · al hacer click (focus) o cuando está seleccionado (--active) se vuelve morado, mantiene >S< y deja escribir */\n.pamount--input:focus-within,\n.pamount--input.pamount--active {\n border-width: 1.5px;\n background-image:\n linear-gradient(165deg, #8460E5 0%, #3B1782 100%),\n linear-gradient(120deg, #ffffff 0%, #F4AC59 22%, #8460E5 75%, #ffffff 100%);\n}\n.pamount--input:focus-within .pamount__radio,\n.pamount--input.pamount--active .pamount__radio { border-color: rgba(255,255,255,0.5); }\n.pamount--input:focus-within .pamount__dot,\n.pamount--input.pamount--active .pamount__dot { opacity: 1; }\n.pamount--input:focus-within .pamount__prefix,\n.pamount--input:focus-within .pamount__num,\n.pamount--input.pamount--active .pamount__prefix,\n.pamount--input.pamount--active .pamount__num { color: #ffffff; }\n.pamount--input:focus-within .pamount__num::placeholder,\n.pamount--input.pamount--active .pamount__num::placeholder { color: rgba(255,255,255,0.65); }\n\n/* selected (morado) */\n.pamount--selected {\n border-width: 1.5px;\n background-image:\n linear-gradient(165deg, #8460E5 0%, #3B1782 100%),\n linear-gradient(120deg, #ffffff 0%, #F4AC59 22%, #8460E5 75%, #ffffff 100%);\n}\n.pamount--selected .pamount__radio { border-color: rgba(255,255,255,0.5); }\n.pamount--selected .pamount__dot { opacity: 1; }\n.pamount--selected .pamount__amount { color: #ffffff; }\n\n.pamount--disabled { cursor: not-allowed; opacity: 0.7; }\n\n@media (prefers-reduced-motion: reduce) { .pamount { transition: none; } }\n`;\n\nlet _stylesInjected = false;\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\nexport default function AmountOption({\n variant = \"default\",\n amount,\n value,\n defaultValue,\n prefix = \">S<\",\n placeholder,\n onValueChange,\n onSelect,\n selected = false,\n disabled = false,\n name,\n \"aria-label\": ariaLabel,\n className = \"\",\n}: AmountOptionProps): JSX.Element {\n const uid = useId().replace(/:/g, \"-\");\n const isSelected = variant === \"selected\";\n const isInput = variant === \"input\";\n const controlled = value !== undefined;\n const sizeText = (controlled ? (value ?? \"\") : (defaultValue ?? \"\")) || placeholder || \"\";\n const inputSize = Math.max(sizeText.length, 1);\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 = AMOUNTOPTION_STYLES;\n document.head.appendChild(el);\n }\n _stylesInjected = true;\n }\n\n const cls = [\n \"pamount\",\n isSelected ? \"pamount--selected\" : \"\",\n isInput ? \"pamount--input\" : \"\",\n isInput && selected ? \"pamount--active\" : \"\",\n disabled ? \"pamount--disabled\" : \"\",\n className,\n ].filter(Boolean).join(\" \");\n\n const radio = (\n \n \n \n );\n\n // Modo input: campo editable con placeholder gris.\n if (isInput) {\n return (\n <>\n