Appearance
Arquitectura Frontend - PWA
Resumen
Frontend PWA independiente y separado del ERP administrativo, optimizado para clientes moviles con enfoque mobile-first. Repositorio independiente (portal-usuarios). Cada tenant recibe su propia imagen Docker con build estatico servido por nginx:alpine. Branding y configuracion de tenant integrados en build time via variables VITE_*.
Stack Tecnico
Decisiones Tecnologicas
| Tecnologia | Version | Rol |
|---|---|---|
| Vite | 6.x | Build tool y dev server |
| React | 19 | Framework UI |
| TypeScript | 5.x | Type safety estricto |
| shadcn/ui | latest | Componentes UI (sobre Radix UI) |
| Radix UI | latest | Primitivos headless accesibles |
| Tailwind CSS | 4.x | Utility-first styling |
| TanStack Router | 1.x | Type-safe routing |
| TanStack Query | 5.x | Server state, cache, polling |
| React Hook Form | 7.x | Forms performantes |
| Zod | 3.x | Validacion de schemas |
| Axios | 1.x | HTTP client |
| vite-plugin-pwa | 0.x | PWA con Workbox |
| Vitest | 3.x | Testing unitario/componentes |
| Testing Library | 16.x | Testing de componentes React |
| Playwright | 1.x | Testing E2E |
Por que Vite?
- Build estatico servido por nginx:alpine en Docker (sin Node.js en produccion)
- HMR instantaneo en desarrollo
- Build optimizado con tree-shaking automatico
- PWA con plugin oficial (vite-plugin-pwa)
- TypeScript nativo sin configuracion adicional
Por que React 19?
- Componentes modernos con hooks
- Ecosistema maduro de herramientas (React Hook Form, Testing Library)
- Facil para equipo PHP que migra a frontend moderno
Por que shadcn/ui + Radix UI?
- Componentes headless: el comportamiento esta separado del estilo
- Theming via CSS variables de Tailwind: mapea perfectamente al branding por tenant
- Copy-paste model: los componentes se copian al proyecto, no se instalan como dependencia
- Accesibilidad integrada (Radix UI maneja ARIA, focus management, keyboard navigation)
- No hay restricciones visuales como en MUI o Ant Design
Por que TanStack Router (no React Router)?
- Type-safe routes: los parametros de URL y search params se validan con TypeScript
- Search params tipados con Zod: filtros y paginacion validados en la URL
- Loader pattern: carga de datos declarativa por ruta
- File-based routing opcional, pero se usa route tree explicito por claridad
Por que TanStack Query (no Zustand ni Context para server state)?
- El portal es 90% server state (deudas, pagos, cupones, cuenta)
- Cache automatico con stale-while-revalidate
refetchIntervalpara polling (actualizaciones de estado de pago)refetchOnWindowFocuspara datos frescos al volver a la app- Mutations con
onSuccesspara invalidar cache relacionado
Por que NO Next.js?
- No se necesita SSR (es una PWA SPA)
- No se necesita servidor Node.js en produccion
- Vite es mas simple y rapido para SPA pura
- El build estatico se sirve directamente con nginx
Arquitectura de Componentes
mermaid
graph TD
subgraph "App Shell"
Router["TanStack Router<br/>(route tree)"]
QC["QueryClientProvider<br/>(TanStack Query)"]
AuthP["AuthProvider"]
BrandP["BrandingProvider"]
Layout["Layout (Header, Nav, Footer)"]
end
subgraph "Pages (routes)"
Login["/login"]
Register["/register"]
ForgotPw["/forgot-password"]
ResetPw["/reset-password"]
Dashboard["/dashboard"]
Deudas["/deudas"]
Pagar["/pagar"]
PagarResult["/pagar/$status"]
Perfil["/perfil"]
Cupones["/cupones"]
Historial["/historial-pagos"]
end
subgraph "Feature Components"
AuthForms["LoginForm, RegisterForm<br/>ForgotPasswordForm, ResetPasswordForm"]
DeudaComps["DeudaCard, DeudaList<br/>DeudaStatusBadge"]
PagoComps["PaymentButton, SelectFacturas<br/>PaymentStatusCard"]
PerfilComps["ProfileForm<br/>ChangePasswordForm"]
CuponComps["CuponCard, CuponList<br/>CuponDownloadButton"]
end
subgraph "UI (shadcn/ui)"
ShadcnUI["Button, Card, Input, Form<br/>Dialog, Badge, Skeleton<br/>Toast, Separator, Label"]
end
subgraph "Data Layer"
TQ["TanStack Query Hooks<br/>useDeudas, usePagos<br/>useCupones, useMiCuenta"]
API["API Client (Axios)<br/>auth.ts, ctacte.ts<br/>pagos.ts, cupones.ts"]
end
subgraph "Contexts"
AuthCtx["AuthContext<br/>(JWT, login, register, refresh)"]
BrandCtx["BrandingContext<br/>(import.meta.env → CSS vars)"]
end
Router --> QC --> AuthP --> BrandP --> Layout
Layout --> Login & Register & ForgotPw & ResetPw
Layout --> Dashboard & Deudas & Pagar & PagarResult
Layout --> Perfil & Cupones & Historial
AuthForms --> AuthCtx
DeudaComps --> TQ
PagoComps --> TQ
CuponComps --> TQ
TQ --> API
AuthForms --> ShadcnUI
DeudaComps --> ShadcnUI
PagoComps --> ShadcnUI
PerfilComps --> ShadcnUI
CuponComps --> ShadcnUICapas:
- App Shell: Providers anidados (Router > QueryClient > Auth > Branding > Layout)
- Pages: Rutas del TanStack Router, cada una con su loader y/o query
- Feature Components: Componentes de dominio que usan hooks de TanStack Query
- UI (shadcn/ui): Componentes de presentacion puros, estilizados con Tailwind
- Data Layer: TanStack Query hooks que consumen la API client (Axios)
- Contexts: Estado global minimo (auth + branding)
Patron Container/Presentational
Los componentes de feature con logica de datos siguen el patron Container/Presentational:
- Container: obtiene datos via TanStack Query, gestiona estado de carga/error, pasa datos como props al presentacional
- Presentational: recibe datos por props, no conoce TanStack Query ni la API, 100% testeable con datos mockados
Componentes refactorizados a este patron:
Deudas:DeudasContainer+DeudasList/DeudaCardCupon:CuponContainer+CuponViewHistorial:HistorialContainer+HistorialList
Ventaja: El presentacional puede testearse con Vitest/Testing Library pasando props directamente, sin necesidad de mockear TanStack Query ni Axios. El container tiene un test de integracion mas reducido.
Estructura de Proyecto
portal-usuarios/
├── src/
│ ├── main.tsx # Entry point: monta RouterProvider
│ ├── router.tsx # Route tree (TanStack Router)
│ ├── query-client.ts # QueryClient singleton con defaults
│ ├── pages/ # Componentes de pagina (route components)
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── ForgotPasswordPage.tsx
│ │ ├── ResetPasswordPage.tsx
│ │ ├── DashboardPage.tsx
│ │ ├── DeudasPage.tsx
│ │ ├── PagarPage.tsx
│ │ ├── PagarStatusPage.tsx # Maneja /pagar/exito, /error, /pendiente
│ │ ├── PerfilPage.tsx # Perfil de usuario + cambiar password
│ │ ├── CuponesPage.tsx
│ │ └── HistorialPagosPage.tsx
│ ├── components/
│ │ ├── ui/ # shadcn/ui components (copy-paste)
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── input.tsx
│ │ │ ├── form.tsx # Form wrapper para React Hook Form
│ │ │ ├── dialog.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── toast.tsx
│ │ │ ├── separator.tsx
│ │ │ └── label.tsx
│ │ ├── layout/ # Header, Footer, Navigation, MobileNav
│ │ ├── auth/ # LoginForm, RegisterForm, etc.
│ │ ├── deudas/ # DeudaCard, DeudaList, DeudaStatusBadge
│ │ ├── pagos/ # PaymentButton, SelectFacturas, PaymentStatusCard
│ │ ├── perfil/ # ProfileForm, ChangePasswordForm
│ │ └── cupones/ # CuponCard, CuponList, CuponDownloadButton
│ ├── lib/
│ │ ├── api/ # Axios client y modulos
│ │ │ ├── client.ts # Axios instance con interceptors
│ │ │ ├── auth.ts # login, register, forgot, reset, refresh
│ │ │ ├── ctacte.ts # mi-cuenta, deudas
│ │ │ ├── pagos.ts # iniciar, historial, cancelar
│ │ │ ├── perfil.ts # getPerfil, updatePerfil, cambiarPassword
│ │ │ └── cupones.ts # generar, listar, detalle, descargar PDF
│ │ ├── validators/ # Zod schemas
│ │ │ ├── auth.ts # loginSchema, registerSchema, cambiarPasswordSchema, etc.
│ │ │ ├── perfil.ts # perfilSchema
│ │ │ └── cupones.ts # generarCuponSchema
│ │ └── utils/ # Formateo de moneda, fechas, etc.
│ ├── hooks/ # Custom hooks
│ │ ├── use-auth.ts # useAuth() — login, register, logout, isAuthenticated
│ │ ├── use-deudas.ts # useDeudas() — TanStack Query
│ │ ├── use-pagos.ts # usePagos(), useIniciarPago() — TanStack Query
│ │ ├── use-cupones.ts # useCupones(), useGenerarCupon() — TanStack Query
│ │ ├── use-mi-cuenta.ts # useMiCuenta() — TanStack Query
│ │ ├── use-perfil.ts # usePerfil(), useUpdatePerfil(), useCambiarPassword()
│ │ ├── use-payment-status.ts # usePaymentStatus() — polling con refetchInterval
│ │ └── use-branding.ts # useBranding() — lee BrandingContext
│ ├── contexts/
│ │ ├── auth-context.tsx # AuthContext + AuthProvider
│ │ └── branding-context.tsx # BrandingContext + BrandingProvider
│ └── types/
│ ├── auth.ts # PortalUser, LoginResponse, etc.
│ ├── ctacte.ts # Deuda, MiCuenta
│ ├── pagos.ts # PagoIniciado, PagoHistorial
│ └── cupones.ts # Cupon, CuponGenerado
├── public/
│ └── icons/ # Iconos PWA (192x192, 512x512)
├── tests/
│ ├── components/ # Tests Vitest + Testing Library
│ │ ├── auth/
│ │ │ ├── LoginForm.test.tsx
│ │ │ └── RegisterForm.test.tsx
│ │ ├── deudas/
│ │ │ └── DeudaCard.test.tsx
│ │ └── pagos/
│ │ └── SelectFacturas.test.tsx
│ ├── hooks/
│ │ ├── use-auth.test.ts
│ │ └── use-deudas.test.ts
│ ├── contexts/
│ │ └── auth-context.test.tsx
│ └── e2e/ # Tests Playwright
│ ├── login.spec.ts
│ ├── register.spec.ts
│ └── payment-flow.spec.ts
├── .env.example
├── tailwind.config.ts
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
├── playwright.config.ts
├── Dockerfile
└── nginx.confTanStack Router
Route Tree
El route tree se define explicitamente en src/router.tsx, no con file-based routing. Esto permite type safety completo y control explicito de la jerarquia de rutas.
typescript
// src/router.tsx
import { createRouter, createRoute, createRootRoute } from '@tanstack/react-router'
import { z } from 'zod'
const rootRoute = createRootRoute({
component: RootLayout, // AuthProvider + BrandingProvider + Layout
})
// -- Rutas publicas (sin JWT) --
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: LoginPage,
})
const registerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/register',
component: RegisterPage,
})
const forgotPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/forgot-password',
component: ForgotPasswordPage,
})
const resetPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/reset-password',
component: ResetPasswordPage,
validateSearch: z.object({
code: z.string().optional(),
}),
})
// -- Rutas protegidas (requieren JWT) --
const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/dashboard',
component: DashboardPage,
beforeLoad: requireAuth,
})
const deudasRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/deudas',
component: DeudasPage,
beforeLoad: requireAuth,
})
const pagarRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pagar',
component: PagarPage,
beforeLoad: requireAuth,
})
const pagarStatusRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pagar/$status', // exito | error | pendiente
component: PagarStatusPage,
beforeLoad: requireAuth,
validateSearch: z.object({
payment_id: z.string().optional(),
external_reference: z.string().optional(),
}),
})
const perfilRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/perfil',
component: PerfilPage,
beforeLoad: requireAuth,
})
const cuponesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/cupones',
component: CuponesPage,
beforeLoad: requireAuth,
validateSearch: z.object({
estado: z.enum(['pending', 'used', 'expired', 'cancelled']).optional(),
page: z.number().int().positive().optional(),
}),
})
const historialRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/historial-pagos',
component: HistorialPagosPage,
beforeLoad: requireAuth,
validateSearch: z.object({
page: z.number().int().positive().optional(),
}),
})
const routeTree = rootRoute.addChildren([
loginRoute,
registerRoute,
forgotPasswordRoute,
resetPasswordRoute,
dashboardRoute,
deudasRoute,
pagarRoute,
pagarStatusRoute,
perfilRoute,
cuponesRoute,
historialRoute,
])
export const router = createRouter({ routeTree })Guard de Autenticacion
typescript
// beforeLoad en rutas protegidas
async function requireAuth({ context }: { context: RouterContext }) {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
}Diagrama de Rutas
mermaid
graph LR
subgraph "Publicas"
L["/login"]
R["/register"]
FP["/forgot-password"]
RP["/reset-password"]
end
subgraph "Protegidas (JWT)"
D["/dashboard"]
DE["/deudas"]
P["/pagar"]
PS["/pagar/$status"]
PR["/perfil"]
C["/cupones"]
H["/historial-pagos"]
end
L -->|registro| R
L -->|olvide password| FP
FP -->|codigo| RP
RP -->|exito| L
L -->|login OK| D
R -->|registro OK| D
D --> DE
D --> PR
D --> C
D --> H
DE -->|seleccionar facturas| P
P -->|redirect gateway| PSTanStack Query
Configuracion del QueryClient
typescript
// src/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutos: datos frescos
gcTime: 1000 * 60 * 10, // 10 minutos: cache en memoria
retry: 1, // 1 retry automatico
refetchOnWindowFocus: true, // refetch al volver a la app
refetchIntervalInBackground: false, // no polling si la app esta en background
},
},
})Query Keys
Todas las queries usan el factory portalKeys centralizado en src/lib/queryKeys.ts. Esto evita strings duplicados y permite invalidaciones granulares consistentes.
typescript
// src/lib/queryKeys.ts
export const portalKeys = {
miCuenta: ['mi-cuenta'] as const,
deudas: ['deudas'] as const,
perfil: ['perfil'] as const,
pagos: {
all: ['pagos'] as const,
historial: ['pagos', 'historial'] as const,
status: (paymentId: string) => ['pagos', 'status', paymentId] as const,
},
cupones: {
all: ['cupones'] as const,
list: (filters: CuponFilters) => ['cupones', 'list', filters] as const,
detail: (id: string) => ['cupones', 'detail', id] as const,
},
} as constRegla: ningun hook de TanStack Query debe definir query keys inline. Siempre usar portalKeys.*.
Hooks con TanStack Query
typescript
// hooks/use-deudas.ts
export function useDeudas() {
return useQuery({
queryKey: queryKeys.deudas,
queryFn: () => ctacteApi.getDeudas(),
})
}
// hooks/use-payment-status.ts — POLLING
export function usePaymentStatus(paymentId: string) {
return useQuery({
queryKey: queryKeys.pagos.status(paymentId),
queryFn: () => pagosApi.getStatus(paymentId),
refetchInterval: 5000, // cada 5 segundos
refetchIntervalInBackground: false, // no en background
enabled: !!paymentId, // solo si hay payment_id
})
}
// hooks/use-pagos.ts — MUTATION
export function useIniciarPago() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: IniciarPagoRequest) => pagosApi.iniciar(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.deudas })
queryClient.invalidateQueries({ queryKey: queryKeys.pagos.all })
},
})
}Flujo de Datos
mermaid
graph TD
subgraph "Componente"
Page["DeudasPage"]
Hook["useDeudas()"]
UI["DeudaList + DeudaCard"]
end
subgraph "TanStack Query"
Cache["Query Cache"]
QO["Query Observer"]
end
subgraph "API Layer"
Axios["Axios Client"]
Backend["Backend API"]
end
Page --> Hook
Hook --> QO
QO -->|cache hit| Cache
QO -->|cache miss o stale| Axios
Axios -->|request + JWT + tenant headers| Backend
Backend -->|JSON response| Axios
Axios -->|data| Cache
Cache -->|re-render| QO
QO -->|data, isLoading, error| Hook
Hook --> UIReact Hook Form + Zod
Schemas de Validacion
typescript
// lib/validators/auth.ts
import { z } from 'zod'
export const loginSchema = z.object({
identifier: z.string().min(7, 'DNI/CUIT requerido').max(11),
identifier_type: z.enum(['dni', 'cuit']),
password: z.string().min(8, 'Minimo 8 caracteres'),
})
export const registerSchema = z.object({
identifier: z.string().min(7, 'DNI/CUIT requerido').max(11),
identifier_type: z.enum(['dni', 'cuit']),
email: z.string().email('Email invalido'),
password: z.string()
.min(8, 'Minimo 8 caracteres')
.regex(/\d/, 'Debe contener al menos un numero'),
password_confirmation: z.string(),
}).refine(
(data) => data.password === data.password_confirmation,
{ message: 'Las passwords no coinciden', path: ['password_confirmation'] }
)
export const forgotPasswordSchema = z.object({
identifier: z.string().min(7, 'DNI/CUIT requerido').max(11),
identifier_type: z.enum(['dni', 'cuit']),
})
export const resetPasswordSchema = z.object({
identifier: z.string().min(7),
identifier_type: z.enum(['dni', 'cuit']),
code: z.string().length(6, 'El codigo debe tener 6 digitos'),
password: z.string()
.min(8, 'Minimo 8 caracteres')
.regex(/\d/, 'Debe contener al menos un numero'),
password_confirmation: z.string(),
}).refine(
(data) => data.password === data.password_confirmation,
{ message: 'Las passwords no coinciden', path: ['password_confirmation'] }
)
export const cambiarPasswordSchema = z.object({
current_password: z.string().min(1, 'Password actual requerido'),
new_password: z.string()
.min(8, 'Minimo 8 caracteres')
.regex(/\d/, 'Debe contener al menos un numero'),
new_password_confirmation: z.string(),
}).refine(
(data) => data.new_password === data.new_password_confirmation,
{ message: 'Las passwords no coinciden', path: ['new_password_confirmation'] }
)
export type LoginInput = z.infer<typeof loginSchema>
export type RegisterInput = z.infer<typeof registerSchema>
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type CambiarPasswordInput = z.infer<typeof cambiarPasswordSchema>typescript
// lib/validators/perfil.ts
import { z } from 'zod'
export const perfilSchema = z.object({
email: z.string().email('Email invalido').optional(),
telefono: z.string().optional(),
}).refine(
(data) => data.email || data.telefono,
{ message: 'Debe modificar al menos un campo' }
)
export type PerfilInput = z.infer<typeof perfilSchema>Integracion con shadcn/ui Form
typescript
// Patron de uso en componentes de formulario
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { loginSchema, LoginInput } from '@/lib/validators/auth'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
function LoginForm() {
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { identifier: '', password: '', identifier_type: 'dni' },
})
const onSubmit = (data: LoginInput) => {
// llamar a authApi.login(data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="identifier"
render={({ field }) => (
<FormItem>
<FormLabel>DNI/CUIT</FormLabel>
<FormControl>
<Input placeholder="12345678" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ... mas campos ... */}
<Button type="submit" className="w-full">Ingresar</Button>
</form>
</Form>
)
}shadcn/ui - Patrones de Uso
Componentes Utilizados
| Componente | Uso en el portal |
|---|---|
Button | Acciones primarias (Ingresar, Pagar, Generar Cupon) y secundarias |
Card | DeudaCard, CuponCard, PaymentStatusCard, DashboardSummaryCard |
Input | Campos de formulario (DNI, email, password, codigo) |
Form | Wrapper de React Hook Form con shadcn/ui |
Dialog | Confirmacion de pago, detalle de cupon |
Badge | Estado de deuda (Vencida, Pendiente), estado de cupon |
Skeleton | Loading states en cards y listas |
Toast | Notificaciones de exito/error |
Separator | Division visual entre secciones |
Label | Labels de formulario |
Theming por Tenant
shadcn/ui usa CSS variables de Tailwind para el theming. El BrandingContext inyecta los colores del tenant:
typescript
// contexts/branding-context.tsx
function BrandingProvider({ children }: { children: React.ReactNode }) {
const branding = {
appName: import.meta.env.VITE_APP_NAME ?? 'Portal de Clientes',
logo: import.meta.env.VITE_LOGO_URL ?? '',
primaryColor: import.meta.env.VITE_PRIMARY_COLOR ?? '#1e40af',
themeColor: import.meta.env.VITE_THEME_COLOR ?? '#1e3a8a',
}
useEffect(() => {
const root = document.documentElement
// shadcn/ui lee --primary para sus componentes
root.style.setProperty('--primary', branding.primaryColor)
root.style.setProperty('--primary-foreground', '#ffffff')
// Meta tag para PWA
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', branding.themeColor)
}, [branding])
return (
<BrandingContext.Provider value={branding}>
{children}
</BrandingContext.Provider>
)
}Contextos (Estado Global)
AuthContext
Proposito: Gestionar autenticacion JWT del cliente en toda la app.
Estado:
cliente: Datos del cliente logueado (nombre, ID, email, DNI/CUIT)isAuthenticated: Si esta autenticadoisLoading: Cargando estado inicial (verificando token al montar)accessToken: JWT access token
Acciones:
login(identifier, password): Delega completamente aauthService.login(). No contiene logica HTTP duplicada — el contexto no hace el fetch directamente, lo delega al servicio.register(identifier, email, password): Auto-registro. Valida que DNI/CUIT exista en ordcon.forgotPassword(identifier): Solicitar codigo de reset via email.resetPassword(code, newPassword): Ingresar codigo recibido por email + nueva password.logout(): Cerrar sesion, limpiar JWT de localStorage, revocar refresh token.refreshToken(): Renovar JWT antes de que expire (llamado automaticamente por interceptor Axios).
Principio de delegacion: AuthContext.login() no duplica logica HTTP. Llama a authService.login() y solo gestiona el estado del contexto (guardar token, actualizar cliente, actualizar isAuthenticated). La logica de red vive en el servicio, no en el contexto.
Almacenamiento del token:
access_tokenenlocalStorage— la app es una SPA sin backend propio, no hay servidor que setee httpOnly cookies- El interceptor de Axios lee el token de localStorage para cada request
- En logout se elimina de localStorage y se revoca el refresh token en el backend
Flujo de autenticacion:
mermaid
sequenceDiagram
participant C as Cliente
participant F as Frontend (React)
participant B as Backend API
alt Registro
C->>F: Ingresa DNI/CUIT + email + password
F->>F: Valida con Zod (registerSchema)
F->>B: POST /portal/auth/register
B->>B: Valida DNI/CUIT contra ordcon
B-->>F: JWT access_token + refresh_token + datos cliente
F->>F: Guarda access_token en localStorage
F->>F: AuthContext.setCliente(datos)
F-->>C: Redirect a /dashboard
end
alt Login
C->>F: Ingresa DNI/CUIT + password
F->>F: Valida con Zod (loginSchema)
F->>B: POST /portal/auth/login
B->>B: Valida credenciales (bcrypt)
B-->>F: JWT access_token + refresh_token + datos cliente
F->>F: Guarda access_token en localStorage
F->>F: AuthContext.setCliente(datos)
F-->>C: Redirect a /dashboard
end
alt Token Refresh (automatico via interceptor)
F->>B: Request con JWT expirado
B-->>F: 401 Unauthorized
F->>F: Interceptor detecta 401
F->>B: POST /portal/auth/refresh-token
B-->>F: Nuevo access_token + refresh_token
F->>F: Actualiza localStorage
F->>B: Retry request original con nuevo token
B-->>F: Response exitosa
end
alt Forgot + Reset Password
C->>F: Ingresa DNI/CUIT en forgot-password
F->>B: POST /portal/auth/forgot-password
B->>B: Genera codigo 6 digitos, envia email
B-->>F: OK (respuesta generica)
F-->>C: Redirect a /reset-password
C->>F: Ingresa codigo + nueva password
F->>F: Valida con Zod (resetPasswordSchema)
F->>B: POST /portal/auth/reset-password
B->>B: Valida codigo, actualiza password (bcrypt)
B-->>F: OK
F-->>C: Redirect a /login con mensaje de exito
endBrandingContext
Proposito: Aplicar branding segun la configuracion compilada en la imagen Docker del tenant.
Estado:
appName: Nombre de la aplicacion (VITE_APP_NAME)logo: URL del logo (VITE_LOGO_URL)primaryColor: Color principal (VITE_PRIMARY_COLOR)themeColor: Color del tema (VITE_THEME_COLOR)
Origen de datos: Variables de entorno inyectadas en build time via Vite (import.meta.env).
No se consulta al backend para obtener branding. Todo esta pre-compilado en la imagen Docker de cada tenant.
Aplicacion:
- Al iniciar la app, BrandingProvider lee valores de
import.meta.env - Se inyectan CSS variables en
document.documentElement:--primary(leido por shadcn/ui para todos sus componentes)--primary-foreground
- Se actualiza
<meta name="theme-color">para la barra de estado del navegador movil - Se carga logo desde la URL configurada en el Header
Nota: No existe TenantContext separado. La informacion de tenant (tenant_id, sucursal_id) se obtiene de las variables de entorno VITE_TENANT_ID y VITE_SUCURSAL_ID, y se envia como headers en cada request API via el interceptor de Axios.
Comunicacion con Backend
API Client (Axios)
typescript
// lib/api/client.ts
import axios from 'axios'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_BACKEND_URL,
timeout: 10_000,
headers: {
'Content-Type': 'application/json',
},
})
// Interceptor de request: JWT + tenant headers
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Tenant context desde variables de entorno (build time)
config.headers['X-Tenant-ID'] = import.meta.env.VITE_TENANT_ID
config.headers['X-Sucursal-ID'] = import.meta.env.VITE_SUCURSAL_ID
return config
})
// Interceptor de response: refresh token en 401
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refresh_token')
const { data } = await axios.post(
`${import.meta.env.VITE_BACKEND_URL}/portal/auth/refresh-token`,
{ refresh_token: refreshToken }
)
localStorage.setItem('access_token', data.data.access_token)
localStorage.setItem('refresh_token', data.data.refresh_token)
originalRequest.headers.Authorization = `Bearer ${data.data.access_token}`
return apiClient(originalRequest)
} catch {
// Refresh fallo: limpiar y redirigir a login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)Normalizacion de errores de API
Todos los errores de la API pasan por normalizeApiError en src/lib/apiError.ts antes de ser expuestos a los componentes. Esto garantiza un formato consistente independientemente de si el error viene de Axios, del backend con JSON de error, o de un timeout de red.
typescript
// src/lib/apiError.ts
export interface NormalizedApiError {
message: string
code?: string
status?: number
}
export function normalizeApiError(error: unknown): NormalizedApiError {
// extrae message, code y status del AxiosError o del error generico
}Regla: los componentes y hooks no acceden directamente a error.response.data. Siempre usar normalizeApiError(error).
Configuracion de entorno con requireEnv
Las variables de entorno se acceden via requireEnv de src/lib/env.ts, no directamente via import.meta.env. requireEnv lanza un error descriptivo en dev si la variable no esta definida, en lugar de silenciosamente usar undefined o un fallback a localhost.
typescript
// src/lib/env.ts
export function requireEnv(key: string): string {
const value = import.meta.env[key]
if (!value) {
throw new Error(`Variable de entorno requerida no configurada: ${key}`)
}
return value
}Uso: requireEnv('VITE_BACKEND_URL') en lugar de import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:8000'.
Motivacion: El fallback a localhost en produccion enmascara errores de configuracion. Con requireEnv, una imagen Docker mal configurada falla inmediatamente con un mensaje claro en lugar de hacer requests silenciosamente a localhost (que nunca responde en produccion).
Modulos API
Cada modulo agrupa endpoints relacionados:
- auth.ts:
login(),register(),forgotPassword(),resetPassword(),refreshToken(),cambiarPassword() - ctacte.ts:
getMiCuenta(),getDeudas() - pagos.ts:
iniciarPago(),getHistorial(),getStatus(),cancelar() - perfil.ts:
getPerfil(),updatePerfil(),cambiarPassword() - cupones.ts:
generar(),getMisCupones(),getDetalle(),descargarPdf()
PDF Cupon - Proxy via Backend
El frontend NO genera PDFs ni accede directamente al servicio de informes. El backend actua como proxy.
Flujo
mermaid
sequenceDiagram
participant C as Cliente
participant F as Frontend
participant B as Backend API
participant I as Informes Service<br/>(puerto 9999)
C->>F: Click "Descargar PDF"
F->>B: GET /portal/cupones/{id}/pdf<br/>Authorization: Bearer {jwt}
B->>B: Validar JWT, resolver tenant
B->>B: Verificar que el cupon pertenece al usuario
B->>I: GET http://localhost:9999/cupon/{id}<br/>(llamada interna)
I-->>B: PDF binary (application/pdf)
B-->>F: Stream PDF<br/>Content-Type: application/pdf<br/>Content-Disposition: attachment
F->>F: Crear blob URL, triggear descarga
C->>C: PDF descargadoImplementacion en el frontend
typescript
// lib/api/cupones.ts
export async function descargarPdf(cuponId: string): Promise<Blob> {
const response = await apiClient.get(`/portal/cupones/${cuponId}/pdf`, {
responseType: 'blob',
})
return response.data
}
// En el componente
function CuponDownloadButton({ cuponId }: { cuponId: string }) {
const handleDownload = async () => {
const blob = await cuponesApi.descargarPdf(cuponId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `cupon-${cuponId}.pdf`
a.click()
URL.revokeObjectURL(url)
}
return <Button onClick={handleDownload}>Descargar PDF</Button>
}Ventajas del proxy:
- El servicio de informes (puerto 9999) nunca se expone al internet
- El backend valida JWT y pertenencia del cupon antes de solicitar el PDF
- El frontend no necesita conocer la URL interna del servicio de informes
- Misma autenticacion y autorizacion que cualquier otro endpoint del portal
Polling - Actualizaciones en Tiempo Real
Estrategia
En vez de WebSockets o SSE, se usa polling automatico con TanStack Query. Esto elimina la necesidad de infraestructura adicional.
Donde se aplica
| Pagina | refetchInterval | Condicion |
|---|---|---|
/pagar/exito | 5 segundos | Mientras status === 'pending' |
/pagar/pendiente | 5 segundos | Mientras status === 'pending' |
/deudas | 10 segundos | Si hay pagos recientes pendientes |
Implementacion
typescript
// hooks/use-payment-status.ts
export function usePaymentStatus(paymentId: string | undefined) {
return useQuery({
queryKey: queryKeys.pagos.status(paymentId!),
queryFn: () => pagosApi.getStatus(paymentId!),
enabled: !!paymentId,
refetchInterval: (query) => {
// Dejar de pollear cuando el pago tiene un estado final
const status = query.state.data?.status
if (status === 'approved' || status === 'rejected' || status === 'cancelled') {
return false // dejar de pollear
}
return 5000 // cada 5 segundos
},
refetchIntervalInBackground: false, // no pollear si la ventana no tiene foco
})
}Diagrama de Polling
mermaid
sequenceDiagram
participant F as Frontend (TanStack Query)
participant B as Backend API
participant DB as portal_payments
Note over F: Usuario en /pagar/pendiente
loop Cada 5 segundos (si status = pending)
F->>B: GET /portal/pagos/status/{id}
B->>DB: SELECT status FROM portal_payments
DB-->>B: status = 'pending'
B-->>F: { status: 'pending' }
F->>F: Re-render con estado actual
end
Note over DB: Webhook del gateway actualiza status
F->>B: GET /portal/pagos/status/{id}
B->>DB: SELECT status FROM portal_payments
DB-->>B: status = 'approved'
B-->>F: { status: 'approved' }
F->>F: Mostrar confirmacion de pago exitoso
F->>F: refetchInterval retorna false, polling se detieneMobile-First Responsive
Principios
El portal esta disenado para uso primario desde celulares. Se parte de la pantalla mas pequena y se escala hacia arriba.
Breakpoints Tailwind
| Breakpoint | Min-width | Uso |
|---|---|---|
| (default) | 0px | Mobile (320px+) |
sm: | 640px | Tableta pequena |
md: | 768px | Tableta |
lg: | 1024px | Desktop |
Patrones Responsive
typescript
// Layout de cards: stack en mobile, grid en desktop
<div className="flex flex-col gap-4 md:grid md:grid-cols-2 lg:grid-cols-3">
{deudas.map(d => <DeudaCard key={d.id} deuda={d} />)}
</div>
// Botones: full-width en mobile, auto en desktop
<Button className="w-full sm:w-auto">Pagar</Button>
// Navegacion: bottom nav en mobile, sidebar en desktop
<nav className="fixed bottom-0 left-0 right-0 md:static md:w-64">
{/* items */}
</nav>
// Padding: menor en mobile, mayor en desktop
<main className="px-4 py-6 md:px-8 lg:px-16">
{children}
</main>Touch Targets
Todos los elementos interactivos tienen un area minima de toque de 44x44px:
typescript
// shadcn/ui Button ya cumple esto por defecto
// Para elementos custom:
<button className="min-h-[44px] min-w-[44px] p-3">
{/* content */}
</button>PWA (Progressive Web App)
vite-plugin-pwa
Configuracion en vite.config.ts:
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate', // actualiza el SW automaticamente
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
// API calls: NetworkFirst (datos frescos, fallback a cache)
urlPattern: /\/portal\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 5, // 5 minutos
},
},
},
{
// Fuentes: StaleWhileRevalidate
urlPattern: /\.(?:woff2?|ttf|otf|eot)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'font-cache',
},
},
],
},
manifest: {
name: process.env.VITE_APP_NAME ?? 'Portal de Clientes',
short_name: process.env.VITE_APP_NAME ?? 'Portal',
description: 'Portal de clientes para consulta de deudas y pagos',
theme_color: process.env.VITE_THEME_COLOR ?? '#1e3a8a',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
],
})Service Worker Strategy
| Tipo de recurso | Estrategia | Cache | Justificacion |
|---|---|---|---|
| App shell (HTML/CSS/JS) | Precache | Automatico al build | Carga instantanea sin red |
API calls (/portal/*) | NetworkFirst | 5 min max | Datos frescos, fallback offline |
| Assets estaticos | CacheFirst | Precache | Imagenes, iconos |
| Fuentes | StaleWhileRevalidate | Persistente | Carga rapida, actualiza en background |
Manifest PWA
El manifest se genera automaticamente con vite-plugin-pwa usando las variables de entorno del tenant:
name:VITE_APP_NAMEtheme_color:VITE_THEME_COLORdisplay:standalone(se ve como app nativa)orientation:portrait(optimizado para mobile)
Testing Strategy
Vitest + Testing Library (componentes y hooks)
Tests rapidos en memoria con jsdom. Se ejecutan con npm run test.
Que testear con Vitest:
- Render de componentes con datos (DeudaCard, CuponCard)
- Validacion de formularios (submit con datos invalidos, mensajes de error)
- Comportamiento de hooks (useAuth login/logout, useDeudas data flow)
- AuthContext (login guarda token, logout limpia, isAuthenticated)
- BrandingContext (lee import.meta.env, aplica CSS variables)
typescript
// Ejemplo: test de LoginForm
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from '@/components/auth/LoginForm'
test('muestra error de validacion con password corto', async () => {
render(<LoginForm />)
await userEvent.type(screen.getByLabelText('DNI/CUIT'), '12345678')
await userEvent.type(screen.getByLabelText('Password'), '123')
await userEvent.click(screen.getByRole('button', { name: 'Ingresar' }))
expect(screen.getByText('Minimo 8 caracteres')).toBeInTheDocument()
})Playwright (E2E - flujos criticos)
Tests de integracion en navegador real. Se ejecutan con npx playwright test.
Que testear con Playwright:
- Registro completo: formulario -> validacion ordcon -> redirect a dashboard
- Login completo: credenciales -> JWT -> dashboard con datos
- Flujo de pago: seleccionar facturas -> iniciar pago -> redireccion a gateway
- Resultado de pago: /pagar/exito con polling de status
- Descarga de PDF cupon
typescript
// Ejemplo: test E2E de login
import { test, expect } from '@playwright/test'
test('login exitoso redirige a dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="identifier"]', '12345678')
await page.fill('[name="password"]', 'password123')
await page.click('button:has-text("Ingresar")')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('text=Hola,')).toBeVisible()
})Cobertura Objetivo
| Tipo | Herramienta | Cobertura |
|---|---|---|
| Componentes + hooks | Vitest + Testing Library | >70% |
| Flujos criticos | Playwright | Login, registro, pago, cupon PDF |
Paginas Principales
1. /login
Login del cliente con DNI/CUIT + password. Formulario con React Hook Form + Zod (loginSchema).
Componentes shadcn/ui: Card, Form, Input, Button
2. /register
Auto-registro. Formulario con React Hook Form + Zod (registerSchema). Valida confirmacion de password.
Componentes shadcn/ui: Card, Form, Input, Button
3. /forgot-password
Solicitar codigo de reset. Formulario con React Hook Form + Zod (forgotPasswordSchema).
Componentes shadcn/ui: Card, Form, Input, Button
4. /reset-password
Ingresar codigo + nueva password. Search param ?code= opcional (TanStack Router validateSearch).
Componentes shadcn/ui: Card, Form, Input, Button
5. /dashboard
Resumen rapido del estado de cuenta. Usa useMiCuenta() (TanStack Query).
Componentes shadcn/ui: Card (summary cards), Button (acciones rapidas), Skeleton (loading)
6. /deudas
Listado de facturas pendientes. Usa useDeudas() (TanStack Query). Cards agrupadas por estado.
Componentes shadcn/ui: Card, Badge (estado: Vencida/Pendiente), Skeleton, Button
7. /pagar
Seleccion de facturas con pago online. Usa useIniciarPago() (TanStack Query mutation). El cliente puede elegir que facturas pagar, pero cada factura seleccionada se paga por el total pendiente.
Seleccion de facturas con monto completo:
- Cada factura se muestra en un Card con checkbox para seleccionarla
- La card muestra
{comprobante} Nro Comp: {nrocomp}, fecha o vencimiento, badge de estado y fondo rojo si esta vencida - Al seleccionar, el monto queda fijado automaticamente en
deuda.debe; no hay input numerico editable - El total se calcula como suma de los saldos completos de las facturas seleccionadas
- El backend rechaza cualquier request con monto parcial mediante
PARTIAL_PAYMENT_NOT_ALLOWED - Dialog de confirmacion muestra el detalle de cada factura con su monto completo
Componentes shadcn/ui: Card, Button, Dialog (confirmacion), Separator, Checkbox (seleccion), Badge
8. /pagar/$status
Resultado del pago. Usa usePaymentStatus() con polling (refetchInterval: 5s).
Componentes shadcn/ui: Card (resultado), Badge (estado), Skeleton (mientras pollea)
9. /cupones
Listado de cupones con filtros. Usa useCupones() con search params validados por Zod.
Componentes shadcn/ui: Card, Badge (estado cupon), Button (descargar PDF, generar)
10. /perfil
Visualizacion y edicion de datos del perfil del usuario. Dos secciones: datos personales y cambio de password.
Seccion datos personales:
- Campos de solo lectura (disabled): nombre, DNI/CUIT
- Campos editables: email, telefono
- Formulario con React Hook Form + Zod (
perfilSchema) - Usa
usePerfil()para cargar datos yuseUpdatePerfil()para guardar
Seccion cambio de password:
- Formulario separado con React Hook Form + Zod (
cambiarPasswordSchema) - Campos: password actual (verificacion), nuevo password, confirmacion
- Usa
useCambiarPassword()(mutation) - Muestra mensaje de exito o error de validacion
Componentes shadcn/ui: Card, Form, Input, Button, Separator (entre secciones), Toast (feedback)
11. /historial-pagos
Historial de pagos realizados. Usa usePagosHistorial() con paginacion en search params.
Componentes shadcn/ui: Card, Badge, Separator
Optimizaciones
Code Splitting
Lazy loading de paginas que no se necesitan en la carga inicial:
typescript
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const CuponesPage = lazy(() => import('./pages/CuponesPage'))
const HistorialPagosPage = lazy(() => import('./pages/HistorialPagosPage'))Prefetching con TanStack Router
typescript
// En el dashboard, prefetch de deudas al hover del link
<Link to="/deudas" preload="intent">Ver deudas</Link>Skeleton Loading
shadcn/ui Skeleton para feedback visual inmediato mientras TanStack Query carga datos:
typescript
function DeudaList() {
const { data, isLoading } = useDeudas()
if (isLoading) {
return Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<Skeleton className="h-24 w-full" />
</Card>
))
}
return data.map(d => <DeudaCard key={d.id} deuda={d} />)
}Flujo de Usuario Completo
- Cliente accede a la URL del portal (imagen Docker de su tenant)
- App carga branding desde
import.meta.env(logo, colores, nombre) via BrandingContext - TanStack Router evalua la ruta y el guard de autenticacion
- Si es nuevo: se registra con DNI/CUIT + email + password (validado con Zod, enviado al backend que valida contra ordcon)
- Si ya tiene cuenta: login con DNI/CUIT + password
- Si olvido password: solicita codigo por email, ingresa codigo + nueva password
- Ve dashboard con resumen de cuenta (TanStack Query:
useMiCuenta()) - Consulta deudas pendientes con indicadores de vencimiento (TanStack Query:
useDeudas()) - Opcion A: Pago Online
- Selecciona facturas
- Confirma pago (Dialog shadcn/ui)
- Mutation
useIniciarPago()crea pago y retorna redirect_url - Redirige a gateway externo
- Gateway redirige de vuelta a
/pagar/$status - Polling con
usePaymentStatus()(refetchInterval: 5s) hasta estado final - Webhook actualiza el estado del pago; la creacion del recibo queda para reconciliacion manual
- Opcion B: Generar Cupon
- Selecciona facturas
- Genera cupon (mutation)
- Descarga PDF via proxy backend (
GET /portal/cupones/{id}/pdf) - Paga en ubicacion fisica
- Ve historial de pagos realizados (TanStack Query:
usePagosHistorial())
Alcance del Pre-Release (portal-client-ux-preproduccion)
Este documento describe la arquitectura planificada completa del portal. La entrega pre-release cubre un subconjunto estable y auditado de esas funcionalidades.
Incluido en pre-release
| Area | Estado |
|---|---|
| Sistema de tokens CSS (HSL, focus-visible, skeleton) | Implementado |
| BrandingContext con conversion hex → HSL | Implementado |
| Primitivos UI: Button, Card, Field, Status (Badge, Alert, EmptyState, Skeleton) | Implementado |
| Layouts: PublicLayout, AuthedLayout mobile-first | Implementado |
| Vistas auth: Login, Register, ForgotPassword, ResetPassword | Implementado |
| Dashboard con estados de carga/vacío/error | Implementado |
| DeudasList con Badge de tono, orden por vencimiento | Implementado |
| PagarView con resumen sticky, confirmación, error inline | Implementado |
| PagoResultado con 4 estados (approved/rejected/cancelled/pending) | Implementado |
| CuponesList con descarga, error no-técnico, Badge de estado | Implementado |
| PerfilView readonly (nombre, email) sin inputs de edición | Implementado |
| Accesibilidad base: aria-live, touch-target 44px, sin tabIndex>0 | Implementado |
UI System: mínimo propio (no shadcn completo)
En el pre-release se usa un sistema UI mínimo creado en src/components/ui/ siguiendo el patrón shadcn copy-paste pero sin instalar la librería completa. Los componentes Button, Card, Field y Status (Badge, Alert, EmptyState, Skeleton) son suficientes para este alcance.
La integración completa de shadcn/ui (Form, Dialog, Toast, Separator, Label) queda como backlog.
Backlog (fuera del alcance pre-release)
Las siguientes funcionalidades están documentadas en este archivo como arquitectura planificada, pero quedan fuera del pre-release. Se implementarán en iteraciones futuras.
| Funcionalidad | Justificación para diferir |
|---|---|
| PWA (vite-plugin-pwa, Service Worker, manifest) | Requiere definir política de cacheo offline y pruebas en dispositivos reales. Infraestructura Docker lista, pendiente de habilitación. |
| Historial de pagos | Endpoint backend disponible, vista y tests pendientes. Backlog de UX para definir filtros y paginación. |
| Perfil editable (email, teléfono, cambio de password) | El PerfilView actual es readonly intencionalmente. Edición y useUpdatePerfil() quedan para el sprint siguiente. |
| Integración completa shadcn/ui | Button, Card, Field, Status son suficientes para pre-release. Form, Dialog, Toast, Separator, Label se integran cuando se habilite la edición de perfil y flujos más complejos. |
| Tests E2E con Playwright | El entorno E2E no está configurado en CI aún. Tests de humo (login → dashboard → deudas → pagar → resultado) se añaden en el siguiente ciclo. |
| Code splitting / lazy loading | Implementar con lazy() una vez que el bundle size sea medible en staging. |