feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador, combobox de unidades, vigencia, PDF ANEXO A - Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE, checklist de expediente, firma digital, 5 PDFs legales - Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF, bitacora, dashboard con filtros - Landing (/): entrada al ecosistema - Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps - 123 tests vitest
This commit is contained in:
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal file
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import type { DatosGenerales } from '../types'
|
||||
import { parsearTextoIne } from '../lib/ocrIne'
|
||||
import type { SugerenciasIne } from '../lib/ocrIne'
|
||||
|
||||
type CamposAplicables = Partial<Pick<DatosGenerales, 'curp' | 'nombreCompleto' | 'fechaNacimiento'>>
|
||||
|
||||
interface Props {
|
||||
/** Imagen (JPG/PNG) de la credencial INE ya cargada */
|
||||
archivo: File
|
||||
/** Aplica un campo sugerido al formulario de datos generales */
|
||||
onAplicar: (campos: CamposAplicables) => void
|
||||
}
|
||||
|
||||
type Estado =
|
||||
| { fase: 'inactivo' }
|
||||
| { fase: 'procesando'; progreso: number }
|
||||
| { fase: 'listo'; sugerencias: SugerenciasIne }
|
||||
| { fase: 'error'; mensaje: string }
|
||||
|
||||
interface SugerenciaVisible {
|
||||
clave: keyof CamposAplicables | 'sexo'
|
||||
etiqueta: string
|
||||
valor: string
|
||||
/** Solo estos campos tienen destino en el formulario */
|
||||
aplicable: boolean
|
||||
}
|
||||
|
||||
function aVisibles(s: SugerenciasIne): SugerenciaVisible[] {
|
||||
const v: SugerenciaVisible[] = []
|
||||
if (s.curp) v.push({ clave: 'curp', etiqueta: 'CURP', valor: s.curp, aplicable: true })
|
||||
if (s.nombre) v.push({ clave: 'nombreCompleto', etiqueta: 'Nombre', valor: s.nombre, aplicable: true })
|
||||
if (s.fechaNacimiento)
|
||||
v.push({ clave: 'fechaNacimiento', etiqueta: 'Fecha de nacimiento', valor: s.fechaNacimiento, aplicable: true })
|
||||
if (s.sexo) v.push({ clave: 'sexo', etiqueta: 'Sexo', valor: s.sexo === 'H' ? 'Hombre' : 'Mujer', aplicable: false })
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR del INE con tesseract.js (import dinámico para no inflar el bundle).
|
||||
* El idioma español se sirve local desde /client/tessdata (base path /client/).
|
||||
*/
|
||||
export default function BotonOcrIne({ archivo, onAplicar }: Props) {
|
||||
const [estado, setEstado] = useState<Estado>({ fase: 'inactivo' })
|
||||
const [aplicados, setAplicados] = useState<string[]>([])
|
||||
|
||||
const leer = async () => {
|
||||
setEstado({ fase: 'procesando', progreso: 0 })
|
||||
setAplicados([])
|
||||
try {
|
||||
const { createWorker } = await import('tesseract.js')
|
||||
const worker = await createWorker('spa', undefined, {
|
||||
langPath: `${import.meta.env.BASE_URL}tessdata`,
|
||||
gzip: false,
|
||||
logger: (m: { status: string; progress: number }) => {
|
||||
if (m.status === 'recognizing text') {
|
||||
setEstado({ fase: 'procesando', progreso: Math.round(m.progress * 100) })
|
||||
}
|
||||
},
|
||||
})
|
||||
try {
|
||||
const { data } = await worker.recognize(archivo)
|
||||
const sugerencias = parsearTextoIne(data.text)
|
||||
if (aVisibles(sugerencias).length === 0) {
|
||||
setEstado({
|
||||
fase: 'error',
|
||||
mensaje: 'No se detectaron datos legibles en la imagen. Intenta con una foto más nítida.',
|
||||
})
|
||||
} else {
|
||||
setEstado({ fase: 'listo', sugerencias })
|
||||
}
|
||||
} finally {
|
||||
await worker.terminate()
|
||||
}
|
||||
} catch {
|
||||
setEstado({ fase: 'error', mensaje: 'No fue posible ejecutar el OCR. Verifica tu conexión e inténtalo de nuevo.' })
|
||||
}
|
||||
}
|
||||
|
||||
const aplicar = (s: SugerenciaVisible) => {
|
||||
if (!s.aplicable) return
|
||||
onAplicar({ [s.clave]: s.valor } as CamposAplicables)
|
||||
setAplicados((prev) => [...prev, s.clave])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">Lectura automática (OCR)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void leer()}
|
||||
disabled={estado.fase === 'procesando'}
|
||||
className="shrink-0 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white disabled:opacity-40"
|
||||
>
|
||||
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{estado.fase === 'procesando' && (
|
||||
<p className="mt-3 text-xs font-light text-neutral-500" role="status">
|
||||
Reconociendo texto de la credencial… {estado.progreso}%
|
||||
</p>
|
||||
)}
|
||||
|
||||
{estado.fase === 'error' && (
|
||||
<p className="mt-3 border border-red-400 px-3 py-2 text-xs text-red-700" role="alert">
|
||||
{estado.mensaje}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{estado.fase === 'listo' && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-light text-neutral-500">
|
||||
Sugerencias detectadas — nada se sobreescribe sin tu confirmación:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{aVisibles(estado.sugerencias).map((s) => (
|
||||
<li key={s.clave} className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span className="font-light text-neutral-900">
|
||||
<span className="mr-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
{s.etiqueta}
|
||||
</span>
|
||||
{s.valor}
|
||||
</span>
|
||||
{s.aplicable &&
|
||||
(aplicados.includes(s.clave) ? (
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-accent">✓</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aplicar(s)}
|
||||
className="shrink-0 border border-neutral-900 bg-transparent px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Aplicar
|
||||
</button>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
apps/alta-clientes/src/components/Campos.tsx
Normal file
108
apps/alta-clientes/src/components/Campos.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useFormContext, get } from 'react-hook-form'
|
||||
|
||||
export interface CampoBaseProps {
|
||||
nombre: string
|
||||
etiqueta: string
|
||||
requerido?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const inputClase =
|
||||
'w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm uppercase transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
|
||||
const labelClase = 'mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
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-neutral-50' : ''}`}
|
||||
{...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 }[]
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
34
apps/alta-clientes/src/components/ChecklistExpediente.tsx
Normal file
34
apps/alta-clientes/src/components/ChecklistExpediente.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { estadoExpediente } from '../lib/expediente'
|
||||
import type { DocumentoCargado } from '../types'
|
||||
|
||||
interface Props {
|
||||
documentos: DocumentoCargado[]
|
||||
firmaDataUrl: string
|
||||
}
|
||||
|
||||
/** Panel en vivo del expediente: "Expediente: N/6" con estado plano por item */
|
||||
export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props) {
|
||||
const estado = estadoExpediente(documentos, firmaDataUrl)
|
||||
return (
|
||||
<section
|
||||
aria-label="Checklist del expediente"
|
||||
className="border border-neutral-200 bg-white px-4 py-3"
|
||||
>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Expediente: {estado.completos}/{estado.total}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{estado.items.map((item) => (
|
||||
<li key={item.clave} className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span className="font-light text-neutral-900">{item.etiqueta}</span>
|
||||
{item.completo ? (
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-accent">✓</span>
|
||||
) : (
|
||||
<span className="text-xs uppercase tracking-widest text-neutral-400">Pendiente</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal file
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import type { DocumentoCargado, SlotDocumento } from '../types'
|
||||
import { TAMANO_MAX_DOCUMENTO } from '../data/constantes'
|
||||
|
||||
interface Props {
|
||||
slot: SlotDocumento
|
||||
etiqueta: string
|
||||
documento?: DocumentoCargado
|
||||
onAgregar: (doc: DocumentoCargado) => void
|
||||
onQuitar: (slot: SlotDocumento) => void
|
||||
}
|
||||
|
||||
function formatoTamano(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
/** Miniatura del documento: imagen con object-cover o recuadro plano para PDF */
|
||||
function Miniatura({ documento, url }: { documento: DocumentoCargado; url: string }) {
|
||||
const esImagen = documento.tipoMime.startsWith('image/')
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
aria-label={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
className="block h-20 w-20 shrink-0 border border-neutral-200 bg-neutral-50"
|
||||
>
|
||||
{esImagen ? (
|
||||
<img src={url} alt={`Vista previa de ${documento.nombre}`} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-neutral-900">PDF</span>
|
||||
<span className="max-w-full truncate px-1 text-[10px] font-light text-neutral-500">{documento.nombre}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/** Slot de carga de un documento (PDF/JPG/PNG, máx 10 MB) con vista previa */
|
||||
export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQuitar }: Props) {
|
||||
const [urlObjeto, setUrlObjeto] = useState('')
|
||||
|
||||
// URL de objeto para la miniatura y para abrir en pestaña nueva; se revoca al quitar/desmontar
|
||||
useEffect(() => {
|
||||
if (!documento) {
|
||||
setUrlObjeto('')
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(documento.archivo)
|
||||
setUrlObjeto(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [documento])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(aceptados: File[]) => {
|
||||
const archivo = aceptados[0]
|
||||
if (!archivo) return
|
||||
onAgregar({ slot, nombre: archivo.name, tamano: archivo.size, tipoMime: archivo.type, archivo })
|
||||
},
|
||||
[slot, onAgregar],
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
maxSize: TAMANO_MAX_DOCUMENTO,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/png': ['.png'],
|
||||
},
|
||||
})
|
||||
|
||||
if (documento) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border border-neutral-200 bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{urlObjeto && <Miniatura documento={documento} url={urlObjeto} />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||
<p className="mt-1 truncate text-sm text-neutral-900">
|
||||
{documento.nombre} · {formatoTamano(documento.tamano)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuitar(slot)}
|
||||
className="shrink-0 border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`cursor-pointer border border-dashed border-neutral-300 bg-white px-4 py-6 text-center transition-colors duration-150 ${
|
||||
isDragActive ? 'border-neutral-900 bg-neutral-50' : 'hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
|
||||
<p className="mt-2 text-xs font-light text-neutral-500">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Seleccionar archivo
|
||||
</button>
|
||||
</div>
|
||||
{fileRejections.length > 0 && (
|
||||
<p className="mt-1 text-xs text-red-700">Archivo rechazado: formato no permitido o mayor a 10 MB</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
apps/alta-clientes/src/components/SectionCard.tsx
Normal file
18
apps/alta-clientes/src/components/SectionCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
titulo: string
|
||||
descripcion?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Tarjeta de sección del wizard */
|
||||
export default function SectionCard({ titulo, descripcion, children }: Props) {
|
||||
return (
|
||||
<section className="border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</h3>
|
||||
{descripcion && <p className="mt-2 text-sm font-light text-neutral-500">{descripcion}</p>}
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
48
apps/alta-clientes/src/components/SignaturePad.tsx
Normal file
48
apps/alta-clientes/src/components/SignaturePad.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import SignatureCanvas from 'react-signature-canvas'
|
||||
|
||||
interface Props {
|
||||
onFirma: (dataUrl: string) => void
|
||||
}
|
||||
|
||||
/** Pad de firma del cliente; exporta PNG base64 al terminar cada trazo */
|
||||
export default function SignaturePad({ onFirma }: Props) {
|
||||
const ref = useRef<SignatureCanvas | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Notifica firma vacía al montar (estado inicial)
|
||||
onFirma('')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const limpiar = () => {
|
||||
ref.current?.clear()
|
||||
onFirma('')
|
||||
}
|
||||
|
||||
const alTerminar = () => {
|
||||
const pad = ref.current
|
||||
if (!pad) return
|
||||
onFirma(pad.isEmpty() ? '' : pad.getTrimmedCanvas().toDataURL('image/png'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border border-neutral-900 bg-white">
|
||||
<SignatureCanvas
|
||||
ref={ref}
|
||||
onEnd={alTerminar}
|
||||
canvasProps={{ className: 'h-40 w-full', 'aria-label': 'Firma del cliente' }}
|
||||
penColor="black"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={limpiar}
|
||||
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Limpiar firma
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
apps/alta-clientes/src/components/Stepper.tsx
Normal file
40
apps/alta-clientes/src/components/Stepper.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
interface Props {
|
||||
pasos: string[]
|
||||
pasoActual: number
|
||||
onIrAPaso: (paso: number) => void
|
||||
}
|
||||
|
||||
/** Stepper de progreso del wizard; permite volver a pasos ya visitados */
|
||||
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
return (
|
||||
<nav aria-label="Progreso">
|
||||
<ol className="flex flex-wrap items-baseline gap-x-6 gap-y-3 border-b border-neutral-200 pb-4">
|
||||
{pasos.map((nombre, i) => {
|
||||
const activo = i === pasoActual
|
||||
const completado = i < pasoActual
|
||||
return (
|
||||
<li key={nombre}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => completado && onIrAPaso(i)}
|
||||
disabled={!completado}
|
||||
aria-current={activo ? 'step' : undefined}
|
||||
className={`text-xs uppercase tracking-widest transition-colors duration-150 ${
|
||||
activo
|
||||
? 'font-bold text-accent'
|
||||
: completado
|
||||
? 'font-medium text-neutral-900 hover:text-accent'
|
||||
: 'font-light text-neutral-400'
|
||||
}`}
|
||||
title={nombre}
|
||||
>
|
||||
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="hidden md:inline">{nombre}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user