Saltar a contenido

Landing

La landing es la web del QR: la página anónima que se abre cuando un cliente escanea el voucher que imprime la caja. Es de un solo uso por voucher, pensada para cargar rápido en el 3G de salón — la audiencia real entra casi siempre desde el celular. No tiene "home": solo se llega por /p/{token}.

Para el backend que la sirve y valida los tokens, ver Backend. Para el formato del token del QR, ver El token del QR.

Stack

Tecnología Versión Para qué
Preact (vía preact/compat) ^10.29 Runtime de UI — reemplaza a React solo acá, por peso (ver abajo)
Vite ^5.4 Build
@preact/preset-vite ^2.10 Plugin de build — alias react/react-dompreact/compat en build time
zxing-wasm ^3.1 Decodificador PDF417 del DNI (carga diferida, fuera del bundle inicial)
vite-plugin-compression2 ^2.5 Precomprime .br/.gz en build time
TypeScript ^5.6 Tipado

Por qué Preact y no React

El código fuente está escrito contra la API de React (hooks, Component de clase para el ErrorBoundary, etc.) y no cambió: @preact/preset-vite alias react/react-dom a preact/compat/preact/jsx-runtime en build time, sin tocar un import. El motivo es peso: el runtime de React (react+react-dom) representaba ~45 KB de los ~51 KB gzip del bundle inicial — la mayor parte no era código propio, era el framework. Preact pesa ~4 KB. Detalle completo, alternativas consideradas y riesgos de compatibilidad evaluados en ADR-019.

El gestor se queda en React 18 sin cambios — es una herramienta interna sin presupuesto de peso.

Pantallas

La landing es un único componente App.tsx con un switch sobre el nombre de la pantalla — sin router (no hace falta: solo hay una ruta de entrada, /p/{token}, y las transiciones son estados internos, no URLs distintas).

Pantalla Cuándo aparece
Reconocido El dispositivo ya tiene un secreto de reconocimiento guardado (ver más abajo) y el voucher todavía no está resuelto — saludo + confirmar de un toque
1a — Pedí tu DNI Primera pantalla del flujo clásico: pide el DNI (a mano o escaneado)
1b — Alta El DNI escaneado/tipeado no está en el padrón — formulario de alta (nombre, apellido, contacto, aceptación de bases)
2 — Confirmar El DNI ya está en el padrón — confirma la participación con un toque, mostrando el nombre y (si aplica) permite completar/corregir email o celular
Éxito Chance sumada — muestra el total acumulado
3 — Ya participó Ese voucher específico ya se usó — muestra la fecha del registro original
4a — Padrón, fuera de vigencia El voucher es válido pero la campaña ya no está vigente — ofrece sumarse solo al padrón
4b — Gracias Cierre del alta al padrón (4a)
Error Rechazo definitivo (voucher inválido, honeypot de bot, etc.) — mensaje genérico, nunca el motivo real
Error de red Fallo de conexión (no de servidor) — pantalla de reintentar, con backoff
Tope El cliente alcanzó el límite diario de participaciones por DNI

Estos nombres de pantalla siguen los "casos" que devuelve el backend (AE, PENDIENTE_DNI, TOPE) — estadoDesdeResolucion() en App.tsx es la traducción caso → pantalla.

Escaneo de DNI (PDF417)

El botón "Escanear DNI" activa la cámara y decodifica el código de barras PDF417 del reverso del DNI argentino, autocompletando nombre/apellido/sexo/fecha de nacimiento en el alta.

La primera implementación usaba BarcodeDetector (Shape Detection API del navegador), pero una prueba en dispositivo real (Chrome de Android, el navegador más común del público objetivo) mostró que no soporta PDF417 sin un módulo extra de Google Play Services — el botón de escaneo prácticamente no aparecía para la mayoría de la audiencia real. Por eso el sistema trae su propio decodificador PDF417 (zxing-wasm), independiente del navegador:

  • Funciona en cualquier navegador con getUserMedia (cámara), no solo donde hay BarcodeDetector.
  • Carga diferida: el módulo (WASM, pesado) entra vía import() dinámico recién al tocar "Escanear" — nunca forma parte del bundle inicial.
  • El .wasm se sirve desde el propio origen (no el CDN por defecto de zxing-wasm), porque el salón donde se escanea puede tener conectividad inestable.
  • Charset: el payload del DNI argentino viene en Latin-1 (ISO-8859-1), no UTF-8. El decodificador expone los bytes crudos y la landing los reinterpreta explícitamente como Latin-1, así apellidos con Ñ o acentos (p. ej. "PEÑA") llegan correctos.

Detalle de la decisión y las alternativas descartadas en ADR-026.

Diagrama

Reconocimiento de dispositivo

En la segunda participación desde el mismo teléfono, la landing puede saludar por nombre y ofrecer confirmar de un toque, sin re-pedir el DNI. El mecanismo es un secreto opaco, nunca el DNI:

  • Al consumir una chance (alta o confirmar), el backend genera un secreto aleatorio (≥128 bits) y lo devuelve una sola vez; la landing lo guarda en localStorage junto al primer nombre — { secreto, nombre }, nada más.
  • El DNI nunca toca el navegador, ni en claro ni hasheado (un DNI son ~90 millones de valores posibles; un hash se revierte por fuerza bruta con la propia app).
  • "No soy yo" borra el secreto guardado y cae al flujo clásico por DNI — protege el caso de un teléfono compartido (la caja, un familiar).
  • Reconocer nunca participa solo: siempre exige el toque explícito de "Confirmar" además de un voucher vigente.

Persistencia en lib/reconocimiento.ts (localStorage, no sessionStorage: el secreto es permanente y cruza campañas). Configurable por cliente vía sp.reconocimiento.habilitado — con la feature apagada, la landing pide siempre el DNI. Detalle completo en ADR-027.

Cómo apunta al backend

const BASE = import.meta.env.VITE_API_BASE ?? "";
  • En producción: BASE queda vacío — todas las llamadas son same-origin, porque el backend sirve la landing y la API desde el mismo jar (ver Backend → build del jar único). No hace falta CORS.
  • En desarrollo (vite dev, puerto 5173): el proxy de Vite reenvía /api, /bases y /branding a http://localhost:8080 (el backend corriendo aparte) — mismo comportamiento same-origin desde el punto de vista del navegador, sin abrir CORS en el backend.
  • VITE_API_BASE permite apuntar explícitamente a otro host si hiciera falta (no se usa en el flujo normal de dev ni de prod).

Presupuesto de peso

La Pantalla 1a tiene que pintar en menos de 1.5 s bajo el perfil "3G lento" de los tests E2E — la mayoría del público entra desde el celular. El presupuesto es 60 KB gzip para el bundle inicial (AC-24), medido automáticamente (npm run build:check) sobre los <script>/<link> que dist/index.html referencia directo — los chunks que solo se alcanzan por import() dinámico (como el escáner de DNI) quedan fuera del presupuesto a propósito, porque no bloquean el primer render.

Con Preact + estado inicial embebido en el HTML (el backend inyecta {token, campania, resolucion} directo en /p/{token}, evitando dos viajes de red antes del primer render), el bundle inicial mide 14.29 KB gzip / 12.89 KB brotli — bien por debajo del límite.

Compresión

vite-plugin-compression2 genera .br/.gz en build time; el backend los sirve tal cual vía EncodedResourceResolver cuando el navegador los acepta, sin gastar CPU comprimiendo al vuelo en cada request.