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-dom → preact/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 (A–E, 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 hayBarcodeDetector. - 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
.wasmse sirve desde el propio origen (no el CDN por defecto dezxing-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.

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
localStoragejunto 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¶
- En producción:
BASEqueda 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, puerto5173): el proxy de Vite reenvía/api,/basesy/brandingahttp://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_BASEpermite 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.