Skip to content

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

TecnologiaVersionRol
Vite6.xBuild tool y dev server
React19Framework UI
TypeScript5.xType safety estricto
shadcn/uilatestComponentes UI (sobre Radix UI)
Radix UIlatestPrimitivos headless accesibles
Tailwind CSS4.xUtility-first styling
TanStack Router1.xType-safe routing
TanStack Query5.xServer state, cache, polling
React Hook Form7.xForms performantes
Zod3.xValidacion de schemas
Axios1.xHTTP client
vite-plugin-pwa0.xPWA con Workbox
Vitest3.xTesting unitario/componentes
Testing Library16.xTesting de componentes React
Playwright1.xTesting 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
  • refetchInterval para polling (actualizaciones de estado de pago)
  • refetchOnWindowFocus para datos frescos al volver a la app
  • Mutations con onSuccess para 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 --> ShadcnUI

Capas:

  1. App Shell: Providers anidados (Router > QueryClient > Auth > Branding > Layout)
  2. Pages: Rutas del TanStack Router, cada una con su loader y/o query
  3. Feature Components: Componentes de dominio que usan hooks de TanStack Query
  4. UI (shadcn/ui): Componentes de presentacion puros, estilizados con Tailwind
  5. Data Layer: TanStack Query hooks que consumen la API client (Axios)
  6. 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 / DeudaCard
  • Cupon: CuponContainer + CuponView
  • Historial: 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.conf

TanStack 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| PS

TanStack 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 const

Regla: 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 --> UI

React 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

ComponenteUso en el portal
ButtonAcciones primarias (Ingresar, Pagar, Generar Cupon) y secundarias
CardDeudaCard, CuponCard, PaymentStatusCard, DashboardSummaryCard
InputCampos de formulario (DNI, email, password, codigo)
FormWrapper de React Hook Form con shadcn/ui
DialogConfirmacion de pago, detalle de cupon
BadgeEstado de deuda (Vencida, Pendiente), estado de cupon
SkeletonLoading states en cards y listas
ToastNotificaciones de exito/error
SeparatorDivision visual entre secciones
LabelLabels 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 autenticado
  • isLoading: Cargando estado inicial (verificando token al montar)
  • accessToken: JWT access token

Acciones:

  • login(identifier, password): Delega completamente a authService.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_token en localStorage — 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
    end

BrandingContext

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:

  1. Al iniciar la app, BrandingProvider lee valores de import.meta.env
  2. Se inyectan CSS variables en document.documentElement:
    • --primary (leido por shadcn/ui para todos sus componentes)
    • --primary-foreground
  3. Se actualiza <meta name="theme-color"> para la barra de estado del navegador movil
  4. 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 descargado

Implementacion 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

PaginarefetchIntervalCondicion
/pagar/exito5 segundosMientras status === 'pending'
/pagar/pendiente5 segundosMientras status === 'pending'
/deudas10 segundosSi 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 detiene

Mobile-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

BreakpointMin-widthUso
(default)0pxMobile (320px+)
sm:640pxTableta pequena
md:768pxTableta
lg:1024pxDesktop

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 recursoEstrategiaCacheJustificacion
App shell (HTML/CSS/JS)PrecacheAutomatico al buildCarga instantanea sin red
API calls (/portal/*)NetworkFirst5 min maxDatos frescos, fallback offline
Assets estaticosCacheFirstPrecacheImagenes, iconos
FuentesStaleWhileRevalidatePersistenteCarga 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_NAME
  • theme_color: VITE_THEME_COLOR
  • display: 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

TipoHerramientaCobertura
Componentes + hooksVitest + Testing Library>70%
Flujos criticosPlaywrightLogin, 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 y useUpdatePerfil() 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

  1. Cliente accede a la URL del portal (imagen Docker de su tenant)
  2. App carga branding desde import.meta.env (logo, colores, nombre) via BrandingContext
  3. TanStack Router evalua la ruta y el guard de autenticacion
  4. Si es nuevo: se registra con DNI/CUIT + email + password (validado con Zod, enviado al backend que valida contra ordcon)
  5. Si ya tiene cuenta: login con DNI/CUIT + password
  6. Si olvido password: solicita codigo por email, ingresa codigo + nueva password
  7. Ve dashboard con resumen de cuenta (TanStack Query: useMiCuenta())
  8. Consulta deudas pendientes con indicadores de vencimiento (TanStack Query: useDeudas())
  9. 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
  10. Opcion B: Generar Cupon
    • Selecciona facturas
    • Genera cupon (mutation)
    • Descarga PDF via proxy backend (GET /portal/cupones/{id}/pdf)
    • Paga en ubicacion fisica
  11. 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

AreaEstado
Sistema de tokens CSS (HSL, focus-visible, skeleton)Implementado
BrandingContext con conversion hex → HSLImplementado
Primitivos UI: Button, Card, Field, Status (Badge, Alert, EmptyState, Skeleton)Implementado
Layouts: PublicLayout, AuthedLayout mobile-firstImplementado
Vistas auth: Login, Register, ForgotPassword, ResetPasswordImplementado
Dashboard con estados de carga/vacío/errorImplementado
DeudasList con Badge de tono, orden por vencimientoImplementado
PagarView con resumen sticky, confirmación, error inlineImplementado
PagoResultado con 4 estados (approved/rejected/cancelled/pending)Implementado
CuponesList con descarga, error no-técnico, Badge de estadoImplementado
PerfilView readonly (nombre, email) sin inputs de ediciónImplementado
Accesibilidad base: aria-live, touch-target 44px, sin tabIndex>0Implementado

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.

FuncionalidadJustificació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 pagosEndpoint 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/uiButton, 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 PlaywrightEl 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 loadingImplementar con lazy() una vez que el bundle size sea medible en staging.