167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import { useFormContext, get } from 'react-hook-form'
|
|
|
|
export interface CampoBaseProps {
|
|
nombre: string
|
|
etiqueta: string
|
|
requerido?: boolean
|
|
className?: string
|
|
}
|
|
|
|
const inputClase =
|
|
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
|
|
|
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
|
|
|
function Etiqueta({ etiqueta, requerido }: { etiqueta: string; requerido?: boolean }) {
|
|
return (
|
|
<span className={labelClase}>
|
|
{etiqueta}
|
|
{requerido && <span className="text-red-700"> *</span>}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** Clase del input según haya error de validación */
|
|
function claseInput(nombre: string, errors: unknown): string {
|
|
return get(errors, nombre) ? `${inputClase} border-red-400` : inputClase
|
|
}
|
|
|
|
export function MensajeError({ nombre }: { nombre: string }) {
|
|
const {
|
|
formState: { errors },
|
|
} = useFormContext()
|
|
const error = get(errors, nombre)
|
|
if (!error) return null
|
|
return <p className="mt-1 text-xs text-red-700">{String(error.message)}</p>
|
|
}
|
|
|
|
interface CampoTextoProps extends CampoBaseProps {
|
|
tipo?: string
|
|
placeholder?: string
|
|
soloLectura?: boolean
|
|
}
|
|
|
|
/** Input de texto conectado a react-hook-form (soporta rutas anidadas con punto) */
|
|
export function CampoTexto({ nombre, etiqueta, tipo = 'text', placeholder, requerido, soloLectura, className }: CampoTextoProps) {
|
|
const { register, formState: { errors } } = useFormContext()
|
|
return (
|
|
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
|
|
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
|
|
<input
|
|
id={nombre}
|
|
type={tipo}
|
|
placeholder={placeholder}
|
|
readOnly={soloLectura}
|
|
className={`${claseInput(nombre, errors)} ${soloLectura ? 'bg-[#F8FAFC]' : ''}`}
|
|
{...register(nombre)}
|
|
/>
|
|
<MensajeError nombre={nombre} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
interface CampoNumeroProps extends CampoBaseProps {
|
|
paso?: string
|
|
}
|
|
|
|
/** Input numérico; cadena vacía se guarda como 0 */
|
|
export function CampoNumero({ nombre, etiqueta, requerido, paso = '0.01', className }: CampoNumeroProps) {
|
|
const { register, formState: { errors } } = useFormContext()
|
|
return (
|
|
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
|
|
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
|
|
<input
|
|
id={nombre}
|
|
type="number"
|
|
step={paso}
|
|
min="0"
|
|
className={claseInput(nombre, errors)}
|
|
{...register(nombre, {
|
|
setValueAs: (v: string) => (v === '' ? 0 : Number(v)),
|
|
})}
|
|
/>
|
|
<MensajeError nombre={nombre} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
interface CampoSelectProps extends CampoBaseProps {
|
|
opciones: { valor: string; etiqueta: string }[]
|
|
}
|
|
|
|
/** CampoSelect (dropdown) conectado a react-hook-form */
|
|
export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) {
|
|
const { register, formState: { errors } } = useFormContext()
|
|
return (
|
|
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
|
|
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
|
|
<select id={nombre} className={claseInput(nombre, errors)} {...register(nombre)}>
|
|
<option value="">— Selecciona —</option>
|
|
{opciones.map((o) => (
|
|
<option key={o.valor} value={o.valor}>
|
|
{o.etiqueta}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<MensajeError nombre={nombre} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Teléfono con selector de lada (México +52 / USA +1). El campo del formulario
|
|
* guarda el valor compuesto "<prefijo> <número>" (p. ej. "+52 6641234567");
|
|
* el dedup del API compara por los últimos 10 dígitos, así que no afecta.
|
|
*/
|
|
export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoBaseProps) {
|
|
const { watch, setValue, formState: { errors } } = useFormContext()
|
|
const valor = ((watch(nombre) as string) ?? '').trim()
|
|
const lada = valor.startsWith('+1') ? '+1' : '+52'
|
|
// Normaliza lo que se muestra: dígitos, últimos 10 (limpia ladas duplicadas
|
|
// o caracteres invisibles heredados de registros viejos)
|
|
const numero = (() => {
|
|
const crudo = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
|
|
const d = crudo.replace(/\D/g, '')
|
|
return d.length > 10 ? d.slice(-10) : d
|
|
})()
|
|
|
|
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
|
|
// Sólo dígitos y máximo 10 (la lada va en el selector)
|
|
const limpio = nuevoNumero.replace(/\D/g, '').slice(0, 10)
|
|
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
|
|
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
|
|
}
|
|
|
|
return (
|
|
<div className={className ?? ''}>
|
|
<label htmlFor={nombre}>
|
|
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<select
|
|
id={`${nombre}-lada`}
|
|
aria-label="Lada"
|
|
value={lada}
|
|
onChange={(e) => actualizar(e.target.value, numero)}
|
|
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
|
>
|
|
<option value="+52">🇲🇽 +52</option>
|
|
<option value="+1">🇺🇸 +1</option>
|
|
</select>
|
|
<input
|
|
id={nombre}
|
|
type="tel"
|
|
inputMode="numeric"
|
|
maxLength={10}
|
|
placeholder="6641234567"
|
|
value={numero}
|
|
onChange={(e) => actualizar(lada, e.target.value)}
|
|
className={claseInput(nombre, errors)}
|
|
/>
|
|
</div>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
|
|
<MensajeError nombre={nombre} />
|
|
</div>
|
|
)
|
|
}
|