# POS Móvil (smartphone) — Análisis, plan e implementación

> Respaldo del trabajo realizado el 2026-07-08 sobre la rama `feat/landing-srpan`.
> Objetivo: versión del punto de venta optimizada para smartphone (venta rápida a una mano),
> con entrega del comprobante por WhatsApp.

---

## 1. Análisis previo: brief vs. realidad del proyecto

Se partió de un brief de diseño móvil genérico (4 pantallas, tab bar, bottom sheets,
carrito global, scanner, entidad cliente). El contraste con el proyecto real obligó a
adaptar varias suposiciones:

| El brief asumía | Realidad del proyecto |
|---|---|
| App de componentes (SPA, stores, tabs) | Monolito **PHP CodeIgniter 4 + Bootstrap 5 (Vuexy/Sneat) + jQuery**, sin bundler |
| 4 pantallas separadas con carrito global | POS de **una sola vista** (`app/Views/start.php`, 2 columnas) con carrito en variable JS local (`let cart = {}`) |
| Búsqueda por código, filtro "bajo stock", thumbnails | La BD **no tiene** código de barras, stock ni imágenes (solo nombre, categoría, precio por tienda) |
| Teléfono del cliente / entidad cliente | **No existe**; el único `phone` es el de la sucursal. `sales` no guarda datos de cliente |
| Enviar comprobante por WhatsApp | Existía el ticket 58mm (`app/Views/ticket/saleTicket.php`) pero **sin WhatsApp**. La única integración WhatsApp estaba en la landing, en dirección inversa (cliente → tienda, número fijo) |
| "Usar tokens del design system" | Convivían **tres** sistemas: Vuexy/Bootstrap (POS admin), tokens Sr. Pan (landing + ticket) y `newStyle.md` (propuesta no cableada) |

**Lo que ya encajaba:** el flujo conceptual completo existía en backend (`Sale::create()`
recalcula precios en servidor vía `SaleItemService`); el ticket ya tenía todos los datos
que pide el mensaje de WhatsApp; la landing ya tenía patrones de bottom-sheet
(`.srpan-bag-drawer`) y barra flotante (`.srpan-bag-bar`) reutilizables como referencia;
y el POS ya usaba targets táctiles de 44-48px.

### Decisiones acordadas (antes de implementar)

1. **Pantallas virtuales en una sola vista/ruta** (`/inicio`): secciones JS que se
   muestran/ocultan, carrito compartido en memoria. No rutas CI4 separadas.
2. **Design system: tokens Sr. Pan** (Fraunces + DM Sans, fondo `#f6efe5`,
   acento `#b98042`) — los mismos de la landing y el ticket. No Vuexy ni `newStyle.md`.
3. **Teléfono del cliente efímero**: se captura en un bottom sheet, valida 10 dígitos MX,
   abre `wa.me/521…` y **no se persiste** (cero migraciones).
4. **"Productos rápidos" = más vendidos** calculados desde `sale_items`
   (por tienda, últimos 30 días, con fallback global). Sin cambios de esquema.

---

## 2. Qué se implementó

### Archivos nuevos

| Archivo | Contenido |
|---|---|
| `app/Views/pos/mobile.php` | Markup de las 4 pantallas virtuales (`#pos-m-home`, `#pos-m-catalog`, `#pos-m-checkout`, `#pos-m-receipt`), barra de carrito (`#pos-m-bag-bar`), tab bar (`#pos-m-tabbar`) y PhoneCaptureSheet (`#pos-m-phone-sheet`, `role="dialog"`). Prefijo `pos-m-` en todos los ids: **cero ids compartidos con el desktop** |
| `public/assets/css/pos-mobile.css` | Tokens `--srpan-*` scoped a `#pos-mobile` (no `:root`, para no filtrar al admin) + componentes `.pos-m-*`, todo bajo `@media (max-width: 991.98px)`. Targets ≥44px, CTA de cobro 56px, safe areas con `env(safe-area-inset-bottom)` |
| `public/assets/js/pos-mobile.js` | IIFE vanilla (patrón `srpan-cart.js`): state machine `goTo()`, renderers de home/catálogo/checkout/recibo, steppers, búsqueda con normalización de acentos (NFD) y debounce 150ms, chips de categoría, PhoneCaptureSheet y `buildReceiptWhatsAppUrl()` (mensaje con folio, líneas, totales, pagado/cambio, método; truncado a 1800 chars; `wa.me/521{tel}?text=` URL-encoded) |

### Archivos modificados

| Archivo | Cambio |
|---|---|
| `app/Views/start.php` | Layout desktop envuelto en `.pos-desktop.d-none.d-lg-block`; `include('pos/mobile')`; link a `pos-mobile.css` + Google Fonts (Fraunces/DM Sans); `const posTopArticlesByStore = <json>`; carga de `pos-mobile.js`. **Refactor del JS core**: evento `pos:cart-changed` (emitido en `setCartItem`, `refreshCartPrices` y reset), `submitSale()` extraída como Promise (guard `isCheckingOut` y validaciones dentro; rechaza con `EMPTY_CART`/`NO_STORE`/`SERVER`), `resetSale()` dividida en `resetCartState()` + `resetDesktopUi()`. Firmas globales intactas (`addToCart`, `setCartItem`, `checkoutCart`… siguen siendo globales porque el HTML usa `onclick=` inline) |
| `app/Controllers/Sale.php` | `create()`: la respuesta conserva las claves originales (el desktop lee `uuid` top-level) y añade `line_items` (article, qty, unit_price, final_price — **recalculados en servidor**, autoritativos) y `created_at_formatted` |
| `app/Controllers/Home.php` | `start()` pasa `$topArticlesByStore` a la vista |
| `app/Services/ReportService.php` | Nuevo `topArticleIdsByStore(int $days = 30, int $limitPerStore = 8)`: agrega `sale_items` por tienda; la clave `0` es el top global (fallback). Las ventas históricas con `store = 0` alimentan solo el global |
| `app/Views/app/layout.php` | Meta viewport con `viewport-fit=cover` (necesario para `env(safe-area-inset-*)` en iPhone con notch; inocuo para el admin) |

### Flujo móvil resultante

```
Home (búsqueda + "Más vendidos" por tienda + bag bar sticky)
  → Catálogo (búsqueda en vivo, chips de categoría, steppers −/+)
  → Cobrar (líneas editables, descuento capado al subtotal, efectivo/tarjeta/transfer,
            recibido → cambio, CTA sticky "Cobrar $X")
  → Recibo (tarjeta estilo ticket con folio, líneas y totales del servidor)
      → [Enviar por WhatsApp] (principal) → PhoneCaptureSheet (valida 10 dígitos)
            → wa.me/521… directo con texto del comprobante + link del ticket
      → [Compartir imagen del recibo] (secundario, solo si el dispositivo soporta
            share de archivos) → PNG del recibo + link vía navigator.share
      → [Nueva venta] → Home limpio (funciona aunque no se envíe el WhatsApp)
      → [Imprimir ticket] → /ticket/{uuid}?print=1 (el ticket 58mm existente)
```

**Comprobante como imagen (agregado después de la entrega inicial):**
- `wa.me` no admite adjuntos, así que la imagen viaja por la hoja de compartir nativa
  (`navigator.share` con archivos). El PNG de `#pos-m-receipt-card` se pre-genera al
  llegar al recibo (html2canvas 1.4.1 vendorizado en
  `public/assets/vendor/html2canvas/`, carga diferida) para que `share()` corra dentro
  del gesto del usuario.
- Ajuste por feedback en iOS: el botón principal "Enviar por WhatsApp" abre WhatsApp
  **directo** (wa.me, sin hoja de compartir del sistema); la imagen es una acción
  secundaria explícita ("Compartir imagen del recibo") que solo aparece cuando el
  dispositivo soporta share de archivos.
- El mensaje (en ambos caminos) incluye el link `https://<dominio>/ticket/{uuid}`.
  Para que el cliente pueda abrirlo, la ruta `/ticket/(:any)` se hizo **pública**
  (`app/Config/Routes.php`): el uuid v6 actúa como token no adivinable. Nota: el
  ticket muestra el nombre del cajero, igual que el impreso.
- En iOS, WhatsApp puede descartar el texto que acompaña a un archivo compartido;
  la imagen siempre llega.

Detalles de comportamiento:
- La barra de carrito y los badges se actualizan vía el evento `pos:cart-changed`; el
  móvil nunca duplica la lógica del carrito, llama a `setCartItem()`/`submitSale()` del core.
- Checkout y Recibo ocultan tab bar y bag bar (`#pos-mobile.is-fullscreen`).
- El primer render se difiere hasta `matchMedia('(max-width: 991.98px)')`; al volver al
  viewport móvil se re-renderiza (el carrito pudo cambiar desde el desktop).
- Guard de efectivo en checkout móvil (`#pos-m-received`): bloquea cobrar si el monto
  está vacío, es 0 o es **menor al total** (banner de error inline; no pierde el carrito).
- `submitSale()` / `getCashReceivedAmount()` (en `start.php`): en viewport móvil (≤991.98px)
  solo lee `#pos-m-received`, no el `#cashreceived` del desktop oculto. El servidor
  (`Sale::create`) revalida `payment.received` en efectivo.
- Inputs de pago hacen `scrollIntoView({block:'center'})` al enfocar (teclado móvil vs
  barras sticky).

---

## 3. Verificación realizada

Entorno: stack local Docker (`panaderia` + `damp-db`), `https://panaderia.test`, con
datos reales (69 artículos, 264 ventas). Se creó un usuario temporal y se automatizó
Chrome (puppeteer-core) en dos viewports. **Todos los artefactos de prueba (2 ventas
móviles + 1 desktop y el usuario) se eliminaron al final; la BD quedó como estaba.**

### Backend (curl)
- `POST /sale` responde 201 con `uuid` top-level intacto + `line_items` +
  `created_at_formatted`; el servidor recalcula importes (se envió `amount=0` y devolvió
  el importe correcto según precios de BD).
- La query de más vendidos devuelve datos correctos por tienda; el HTML renderiza
  `posTopArticlesByStore = {"2":[4,5,7],"0":[60,7,42,4,5,6,2]}`.
- `/ticket/{uuid}` sigue funcionando para las ventas creadas desde el móvil.

### Móvil (Chrome headless, 390×844, touch)
- Home: layout móvil visible, desktop oculto, chips de más vendidos de la tienda
  seleccionada; tap agrega al carrito y actualiza badge + bag bar ("2 artículos · $50.00").
- Catálogo: búsqueda "cafe" encuentra "Panque de café entero" (normalización de acentos);
  steppers funcionan; 46 filas renderizadas.
- Checkout: fullscreen sin tab bar, líneas editables, descuento $5 capado, cambio
  correcto ($600 recibidos − $499 = $101). El guard de efectivo insuficiente bloqueó un
  intento con $200 (comportamiento esperado).
- Venta real registrada (folio, líneas y totales del servidor en el recibo).
- PhoneCaptureSheet: botón deshabilitado con número inválido, habilitado con 10 dígitos;
  el deep link generado fue
  `https://wa.me/5212291234567?text=*Sr. Pan* — Huixtla / Ticket #266 …` con líneas,
  subtotal, descuento, TOTAL, pagado, cambio, método y pie.
- "Nueva venta": Home limpio, sin badges ni bag bar.
- Cero errores de JS en consola.

### Desktop (Chrome headless, 1366×900) — regresión del refactor
- Layout 2 columnas intacto, móvil oculto.
- Venta completa: checkbox → stepper "+" → recibido $500 → cambio $450 → "Imprimir
  ticket" abre popup `/ticket/{uuid}?print=1` → reset completo (checkboxes, steppers,
  inputs de pago). Cero errores de JS.

### Sintaxis
- `php -l` OK en los 3 PHP modificados; `node --check` OK en `pos-mobile.js` y en el JS
  inline extraído de `start.php`.

---

## 4. Pendientes y notas

- **Dato sucio en dev**: el artículo `id=54` tiene el nombre vacío en la BD — aparece
  como "Artículo" en el recibo móvil y como fila sin nombre en cualquier lista (también
  afecta al desktop). Corregirlo desde el admin de artículos.
- **Prueba en teléfono físico** pendiente: deep link real de WhatsApp y safe areas con
  notch no se pueden simular en headless.
- **Paso opcional no implementado** (previsto en el plan como mejora): `syncDesktopControls()`
  para sincronizar los checkboxes/inputs del desktop cuando el carrito se muta desde el
  móvil y se redimensiona a mitad de venta. Los totales sí quedan consistentes; solo los
  controles visuales del desktop podrían desincronizarse en ese caso borde.
- Los cambios quedaron **sin commitear** sobre `feat/landing-srpan`; considerar moverlos
  a una rama propia (p. ej. `feat/pos-mobile`) ya que no son parte de la landing.
- El número destino usa siempre prefijo `521` (México, móvil); si algún día se opera en
  otro país habrá que parametrizarlo.
- El teléfono capturado **no se guarda** en ningún lado por decisión de diseño; si más
  adelante se quiere reenviar sin reescribir o tener historial, el camino es una columna
  `customer_phone` en `sales` o una entidad cliente.

## 5. Referencias de código útiles

- Core del carrito y `submitSale()`: `app/Views/start.php` (bloque `<script>` final)
- Builder del mensaje WhatsApp: `buildReceiptWhatsAppUrl()` en `public/assets/js/pos-mobile.js`
- Patrón original del deep link (landing): `buildWhatsAppUrl()` en `public/assets/js/srpan-cart.js`
- Tokens de marca fuente: `public/assets/css/srpan-landing.css` (`:root`, líneas 1-18)
- Ticket 58mm: `app/Views/ticket/saleTicket.php` + `app/Config/Ticket.php`
