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/>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 --> 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)

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

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 const

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): 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_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)
  }
)

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 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 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 procesa pago automaticamente y crea recibo
  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())