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/>FacturaAmountInput, 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)
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, FacturaAmountInput, 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
Convencion para query keys que permite invalidacion granular:
typescript
export const queryKeys = {
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 constHooks 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): Login con DNI/CUIT + password. Retorna JWT.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).
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)
}
)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 soporte de pago parcial y pago online. Usa useIniciarPago() (TanStack Query mutation).
Seleccion de facturas con monto parcial:
- Cada factura se muestra en un Card con checkbox para seleccionarla
- Al seleccionar, se muestra un campo Input numerico (
FacturaAmountInput) pre-llenado con el saldo completo de la factura - El cliente puede modificar el monto a un valor parcial (> 0 y <= saldo)
- El total se calcula como suma de los montos seleccionados (se actualiza en tiempo real)
- Validacion Zod: cada monto debe ser positivo y no exceder el saldo de la factura
- Dialog de confirmacion muestra el detalle de cada factura con su monto (parcial o completo)
Componentes shadcn/ui: Card, Button, Dialog (confirmacion), Separator, Input (monto parcial), Checkbox (seleccion)
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 procesa pago automaticamente y crea recibo
- 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())