Live production tool used by a construction company to generate and share branded PDF quotes from mobile, with one-tap WhatsApp sharing. Full account system (JWT auth, email verification, password recovery, rate limiting) built on Vercel serverless functions + MongoDB, alongside a fully offline anonymous mode. Installable, offline-capable PWA.
README
Aplicación web para armar y descargar presupuestos en PDF, pensada para usarse desde el celular directamente en la obra. Se puede usar sin cuenta (modo anónimo, 100% en el navegador, historial en localStorage) o con cuenta (registro/login, historial respaldado en MongoDB y accesible desde cualquier dispositivo). El backend es un puñado de funciones serverless de Vercel; no hay un servidor propio que mantener.
Un contratista (o quien cargue el presupuesto) completa un formulario corto con los datos de un trabajo, ve una vista previa en vivo con el mismo diseño que va a tener el PDF final, y con un botón descarga un PDF profesional listo para mandarle al cliente — o lo comparte directo por WhatsApp sin descargar nada. Cada presupuesto generado queda en un historial local, numerado correlativamente (N° 001, N° 002, ...), que se puede buscar, duplicar como base para uno nuevo, o exportar/importar como backup.
No hace falta conexión a internet para generar presupuestos (salvo la primera carga de la página): tanto el armado del PDF como el guardado del historial ocurren enteramente en el dispositivo.
| Campo | Tipo | Detalle | |---|---|---| | Obra / Cliente | texto libre | Nombre de la obra o del cliente (ej: "Laprida"). Es obligatorio. | | Título del trabajo | texto libre | Ej: "Pintura exterior e impermeabilización". | | Alcance del trabajo | textarea | Un ítem por renglón. Cada línea no vacía se convierte en un ítem con viñeta en la vista previa y el PDF. | | Materiales incluidos | checkbox | Si está tildado, agrega automáticamente "Materiales incluidos" como último ítem del alcance (no hace falta escribirlo a mano). | | Valor ($) | numérico | Monto base del presupuesto, sin IVA. | | + IVA | checkbox | Si está tildado, el valor final se calcula como el monto base + 21% de IVA, y tanto la vista previa como el PDF muestran el desglose . Si no está tildado, se muestra un único monto ("Valor total"). | | % Anticipo | numérico (default 70) | Porcentaje que se pide de anticipo. El resto ("% de saldo") se calcula solo como y aparece en el texto de condiciones de pago. | | Validez (días) | numérico (default 15) | Cuántos días es válido el presupuesto desde la fecha de emisión. |
100 - anticipoEl botón Descargar PDF y Compartir por WhatsApp quedan deshabilitados hasta que el formulario sea válido (obra cargada, valor mayor a 0, % de anticipo entre 0 y 100, validez mayor a 0 días).
A medida que se completa el formulario, a la derecha (o abajo en mobile) se actualiza en tiempo real una vista previa con estética de "ficha de obra": logo de la empresa, número de presupuesto, título y obra, lista de ítems del alcance, caja destacada con el valor (con desglose de IVA si corresponde), condiciones de pago, validez y fecha de emisión. El PDF descargado tiene exactamente el mismo contenido y orden que esta vista previa.
Un clic en Descargar PDF arma un documento en tamaño A4 con:
El archivo se descarga con el nombre Presupuesto_<obra>_Braian_Costa_Construcciones.pdf (los espacios en el nombre de la obra se reemplazan por guiones bajos).
El botón Compartir por WhatsApp genera el PDF y abre la bandeja de compartir nativa del celular (Web Share API) con el archivo ya adjunto y un texto resumen — se elige WhatsApp (o cualquier otra app) y se envía el PDF real, sin pasar por la descarga manual. En navegadores que no soportan compartir archivos (por ejemplo, desktop), cae automáticamente al comportamiento anterior: abre wa.me con el texto del presupuesto precargado (ahí sí, sin el PDF adjunto, porque un link de WhatsApp no puede llevar un archivo).
Cada vez que se descarga un PDF, el presupuesto completo queda guardado en el historial, mostrando número, obra, valor y fecha, con paginado de a 10 por página. Incluye:
localStorage; con cuenta, la búsqueda se resuelve en el servidor contra MongoDB.En modo anónimo el historial local sigue limitado a los últimos 20 presupuestos (los más viejos se descartan al agregar uno nuevo, ver Limitaciones conocidas). Con cuenta no hay ese límite: MongoDB guarda todo el historial y el panel lo recorre paginado, sin perder presupuestos viejos.
El panel de historial tiene:
localStorage) que logueado (contra la cuenta en MongoDB).Limpia el formulario dejando precargados los ítems de alcance por defecto ("Provisión de materiales" y "Mano de obra"), e incrementa el número de ticket para el próximo presupuesto.
En el header de la app se ve el estado de la sesión:
localStorage de ese navegador — si se borra el caché o se cambia de dispositivo, se pierden (salvo que se haya exportado un backup a mano).Cambiar entre modo anónimo y con cuenta no mezcla los datos de ambos: cada uno tiene su propio historial y numeración independientes.
Registrarse no deja la cuenta lista para usar todavía: se manda un email con un link para confirmarla (vence a las 24 horas), y hasta que no se hace clic ahí, el login rechaza esa cuenta con el mensaje "Confirmá tu email para poder iniciar sesión". Si el link se perdió o venció, el propio formulario de login ofrece un botón "Reenviar email de verificación" para pedir uno nuevo (pide el reenvío por email, sin necesidad de estar logueado). Recuperar la contraseña por email también cuenta como confirmación, ya que implica probar que se controla esa casilla.
Esto evita que alguien registre una cuenta usable con un email que no le pertenece: la cuenta existe, pero no sirve para nada hasta que el dueño real del email confirme haciendo clic en el link.
Si ya tenías cuentas creadas en MongoDB antes de este cambio, van a quedar marcadas como no confirmadas (el campo no existía) y no van a poder loguearse hasta usar "Reenviar email de verificación" desde el login y confirmar. Las sesiones que ya estuvieran activas en el navegador siguen funcionando hasta que venzan (30 días) o se cierre sesión — el chequeo es solo al iniciar sesión, no en cada request.
Los endpoints de auth que importan (los que verifican contraseña o mandan un email) tienen un límite de intentos por ventana de tiempo, contado en MongoDB (colección RateLimit, con TTL index para que los contadores se autolimpien):
| Endpoint | Por IP | Por email | |---|---|---| | Login | 20 / 15 min | 8 / 15 min | | Registro | 10 / hora | — | | Olvidé mi contraseña | 10 / hora | 4 / hora | | Reenviar confirmación | 10 / hora | 4 / hora | | Resetear contraseña (con token) | 30 / hora | — | | Confirmar email (con token) | 30 / hora | — |
El límite por email en "olvidé mi contraseña" y "reenviar confirmación" es lo que evita que alguien use esos formularios para bombardear de emails la casilla de otra persona, más allá de cuántas IPs use. Al pasarse el límite, la respuesta es un 429 con code: RATE_LIMITED y un mensaje tipo "Demasiados intentos. Probá de nuevo en 5 minutos.", que se muestra tal cual en el formulario.
| Herramienta | Uso |
|---|---|
| Vite | Build tool y dev server |
| React 19 + TypeScript (strict: true) | UI, tipado estricto en todo el proyecto (sin any) |
| Tailwind CSS v4 | Estilos, con una paleta de colores propia (ver Personalización) |
| jsPDF | Generación del PDF, 100% en el navegador |
| vite-plugin-pwa | Manifest + service worker para instalar la app y que funcione offline |
| Vercel Serverless Functions (/api) | Backend: registro/login/logout/recuperación de contraseña y CRUD del historial de la cuenta |
| MongoDB + Mongoose | Persistencia de usuarios y presupuestos de las cuentas registradas |
| bcryptjs + jsonwebtoken | Hash de contraseñas y sesión vía JWT en cookie httpOnly |
| Resend | Envío del email de recuperación de contraseña y del de confirmación de cuenta |
| ESLint (flat config) + Prettier | Lint y formato de código |
En modo anónimo no hay dependencias de red en tiempo de uso (más allá de la carga inicial de la página). Con cuenta, el historial y el número de ticket se leen/escriben contra /api/quotes/*.
Requiere Node.js (18+) y npm. El modo anónimo funciona con solo esto:
npm install
npm run dev
Abrí la URL que muestra la consola (por defecto http://localhost:5173).
npm run dev solo levanta el frontend (Vite) — las funciones de /api no corren ahí. Para probar el flujo completo de cuentas en local hace falta el Vercel CLI, que sirve frontend y backend juntos:
.env.example a .env y completá las variables (ver detalle de cada una en el archivo): MONGODB_URI (tu cluster de Atlas), JWT_SECRET, RESEND_API_KEY, EMAIL_FROM, APP_URL.npx vercel dev
http://localhost:3000).npm run dev # Servidor de desarrollo con hot reload (solo frontend, sin /api)
npm run build # Type-check (frontend + api) + build de producción en dist/
npm run preview # Sirve el build de producción localmente (con el service worker activo, sin /api)
npm run lint # ESLint sobre todo el proyecto (incluye /api)
npm run build
Genera la carpeta dist/ con el frontend estático; las funciones de /api se compilan aparte al deployar (no forman parte de dist/).
Al ser un proyecto Vite estándar, Vercel lo detecta automáticamente: solo hace falta conectar el repositorio (comando de build npm run build, carpeta de salida dist) y Vercel también deploya /api/** como funciones serverless sin configuración adicional — vercel.json solo agrega el rewrite de SPA necesario para que /reset-password no dé 404.
Antes de deployar hay que cargar en Project Settings → Environment Variables las mismas variables que en .env.example: MONGODB_URI, JWT_SECRET, RESEND_API_KEY, EMAIL_FROM y APP_URL (con la URL pública real del deploy). Sin MONGODB_URI/JWT_SECRET el registro/login devuelve error 500, pero el modo anónimo sigue funcionando igual.
Una vez deployado, el sitio ya sirve por HTTPS, así que la instalación como PWA, el modo offline y las cookies de sesión (que requieren HTTPS en producción) funcionan sin pasos extra.
Una vez que la app se sirve por http/https (con npm run preview, o ya deployada), abrila desde el celular: el navegador va a ofrecer "Agregar a pantalla de inicio" / "Instalar app" (Chrome/Android) o "Compartir → Agregar a pantalla de inicio" (Safari/iOS).
Una vez instalada:
localStorage, que tampoco depende de la conexión.src/
domain/ → Tipos, constantes, formateo y validaciones. Funciones puras,
sin dependencias de React ni del DOM (las usa también el backend).
types.ts Quote, ScopeItem, PaymentTerms, HistoryEntry, BackupPayload
constants.ts Nombre y contacto de la empresa (fijos), valores por defecto
formatters.ts formatMoney, formatDate, parseScopeText, buildWhatsappText, etc.
validators.ts Validación de campos + email/password + parseo seguro de un backup
services/
storage/QuoteRepository.ts Interfaz async del historial y el contador de ticket
LocalStorageQuoteRepository Implementación en localStorage (modo anónimo)
storage/ApiQuoteRepository.ts Implementación contra /api/quotes/* (modo con cuenta)
api/httpClient.ts fetch con manejo de errores compartido por ambos
pdf/ Generación del PDF con jsPDF
PdfGenerator.ts Orquesta las secciones y arma el documento final
sections/ Cada función dibuja una parte del PDF (header, scope,
pricing, footer, signature) y devuelve el Y donde
sigue el contenido — agregar una sección nueva no
requiere tocar las existentes
share/ShareService.ts Comparte el PDF vía Web Share API, con fallback a wa.me
backup/BackupService.ts Exporta/lee el archivo JSON de backup
contexts/
AuthContext.tsx AuthProvider + useAuth(): estado de sesión (anónimo/logueado),
login/register/logout/forgotPassword/resetPassword
hooks/
useQuoteForm.ts Estado del formulario, validación, y el Quote derivado
useQuoteHistory.ts Lectura/escritura del historial vía QuoteRepository
useBackup.ts Exportar/importar backup vía BackupService + QuoteRepository
components/
form/ Inputs del formulario (SiteInput, ScopeTextarea, AmountFields, etc.)
preview/ El "ticket" de vista previa (QuotePreview, LogoHeader, AmountBox, etc.)
layout/ AppLayout, Header (muestra el estado de sesión vía useAuth())
history/ HistoryPanel, HistoryItem, BackupActions
auth/ AuthModal (login/registro/olvidé mi contraseña), ResetPasswordPage,
VerifyEmailPage
assets/logo.ts Logo de la empresa embebido en base64 (usado por la vista previa y el PDF)
App.tsx Composition root: AuthProvider + elige QuoteRepository según la sesión
main.tsx
index.css Directivas de Tailwind + paleta de colores propia
api/ → Funciones serverless de Vercel (backend). No corren con `npm run dev`,
solo con `vercel dev` o ya deployadas.
_lib/
db.ts Conexión a MongoDB (Mongoose) cacheada entre invocaciones; el nombre
de la base ("quote-generator") está fijo acá, no depende de la URI
models/User.ts email, passwordHash, currentNumber, emailVerified, tokens de
reset de contraseña y de confirmación de email
models/Quote.ts Un documento por presupuesto guardado, con userId
models/RateLimit.ts Contador por key (IP o email) con TTL index para el rate limiting
auth.ts Hash/verificación de contraseña, JWT, cookie de sesión httpOnly
email.ts Envío por Resend del email de recuperación de contraseña y
del de confirmación de cuenta
rateLimit.ts enforceRateLimit() — lo usan los endpoints de auth (ver
sección Rate limiting)
auth/ register.ts, login.ts, logout.ts, me.ts, forgot-password.ts,
reset-password.ts, verify-email.ts, resend-verification.ts —
cada uno delega en api/_lib/authHandlers.ts
quotes/
index.ts GET historial + número actual, POST agrega un presupuesto
increment-number.ts POST, incrementa el número de ticket de forma atómica
import.ts POST, reemplaza el historial (backup)
migrate.ts POST, suma el historial anónimo a la cuenta sin borrar nada
public/
logo.png, favicon.png, pwa-*.png Copias del logo para uso web/PWA
El código sigue SRP y Open/Closed de forma deliberada:
jsPDF ni a wa.me directamente (Header y los componentes de auth/ son la excepción deliberada: consumen useAuth() directamente porque el estado de sesión es un concern transversal, no algo que tenga sentido pasar prop a prop desde App.tsx).domain/, como funciones puras sin dependencias de React — reutilizadas tal cual desde el backend (api/), testeables de forma aislada aunque hoy no haya tests escritos.QuoteRepository, PdfGenerator, ShareService, BackupService), inyectadas con un valor por defecto en los hooks. QuoteRepository es async precisamente para poder tener dos implementaciones intercambiables sin tocar los hooks ni los componentes: LocalStorageQuoteRepository (anónimo) y ApiQuoteRepository (con cuenta) — App.tsx elige cuál inyectar según useAuth().status.drawHeader, drawScope, drawPricing, drawFooter, drawSignature), cada una una función pura que dibuja su parte y devuelve dónde debe continuar la siguiente. Agregar una sección nueva, o cambiar el orden, no requiere reescribir las existentes.Todo el código (variables, funciones, tipos, nombres de archivo) está en inglés; el único texto en español es el que ve el usuario final: labels del formulario, botones, mensajes de error/confirmación, el contenido del PDF, y el mensaje de WhatsApp.
| Qué cambiar | Dónde |
|---|---|
| Nombre de la empresa | COMPANY_NAME en src/domain/constants.ts |
| Teléfono / Instagram / sitio web | COMPANY_CONTACT en src/domain/constants.ts |
| % de IVA | TAX_PERCENTAGE en src/domain/formatters.ts |
| % de anticipo y días de validez por defecto | DEFAULT_DEPOSIT_PERCENTAGE / DEFAULT_VALIDITY_DAYS en src/domain/constants.ts |
| Ítems de alcance precargados en "Nuevo presupuesto" | DEFAULT_SCOPE_TEXT en src/domain/constants.ts |
| Logo | Reemplazar public/logo.png, regenerar el base64 (base64 -i public/logo.png) y pegarlo en LOGO_BASE64 (src/assets/logo.ts). También conviene actualizar public/favicon.png y los íconos public/pwa-*.png para que coincidan. |
| Colores (paleta petróleo/hueso/óxido) | Variables --color-petrol-*, --color-bone-*, --color-rust-* en src/index.css |
| Tipografías | --font-display (títulos) y --font-mono (montos/números) en src/index.css |
Modo anónimo: no hay servidor involucrado — el historial de presupuestos y el número de ticket se guardan únicamente en el localStorage del navegador donde se usa la app. Esto significa que:
Con cuenta: el historial y el número de ticket se guardan en MongoDB, asociados a esa cuenta (no se comparten entre cuentas distintas). La contraseña se guarda hasheada (nunca en texto plano); la sesión se maneja con un JWT en una cookie httpOnly (no accesible desde JavaScript del lado del cliente). El único servicio de terceros involucrado es Resend, usado para el email de recuperación de contraseña y el de confirmación de cuenta (ver Confirmación de email).
html2canvas y dompurify), lo que deja el chunk principal en ~795 KB sin comprimir (~325 KB con gzip). No afecta la experiencia de uso normal, pero Vite avisa sobre el tamaño en el build.http/https; no funciona abriendo dist/index.html como archivo local.localStorage; los más viejos quedan fuera de la vista (y se pierden) al agregar uno nuevo. Con cuenta no hay este límite: MongoDB guarda todo el historial y se recorre paginado desde el panel.EMAIL_FROM por defecto (onboarding@resend.dev), Resend solo entrega a la casilla verificada de la cuenta de Resend — hace falta verificar un dominio propio en resend.com/domains y actualizar EMAIL_FROM para poder registrar o recuperar la contraseña de cualquier usuario real.