# PROMPT — Optimización de la identificación antes de subirla · lado FRONT (`CL032FRONT`)

> **Ejecutar en:** `D:\FOURSIDES\CL001ASTU\PRE-PRODUCCION\VILLASTUR\CL032RESERVACIONESENLINEA\CL032FRONT`
> **Contraparte:** `CL032BACK` — validación de subida en `max:12288` (12 MB), ya alineada con Tlalpan
> **Contexto del canal:** `CL032BACK/NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md` §2

Actúa como desarrollador senior de este proyecto. Vas a comprimir la imagen de la identificación **en el navegador, antes de subirla**, en el flujo de captura de invitados.

---

## Por qué esto existe — léelo, cambia las decisiones

No es una mejora de rendimiento opcional. Hay cuatro motivos, y el tercero es el menos evidente:

1. **Peso y experiencia.** Una foto de celular pesa 2–12 MB. Sobre 4G son entre 8 y 40 segundos de subida, con el socio mirando una pantalla quieta y sin saber si la solicitud avanzó.

2. **Privacidad.** Son fotografías de identificaciones oficiales **de terceros** (los invitados, no el socio). Un JPEG de celular lleva EXIF con **coordenadas GPS** del lugar donde se tomó, modelo y número de serie del equipo. Un socio que fotografía la credencial de su invitado en su casa está adjuntando su domicilio, sobre un documento de identidad ajeno. Re-codificar en canvas **descarta el EXIF**, porque el canvas no lo copia. Es un beneficio central, no un efecto secundario.

3. **⚠️ El efecto en cadena sobre el canal con Tlalpan.** El sistema del club descarga cada identificación desde el Portal, y una descarga que se agota hace dos cosas: consume uno de los 5 intentos de ese documento **y** suma a un *circuit breaker*. Al quinto fallo el breaker se abre y **detiene la descarga de todos los demás documentos durante 10 minutos**. La foto grande no solo se perjudica a sí misma: frena el canal completo.

   Ese timeout ya se ajustó a **120 segundos** para acompañar el tope de 12 MB, así que el escenario dejó de ser probable — pero el margen depende del enlace, no de la configuración: 12 MiB en 120 s exigen ~**0.84 Mbps sostenidos**, y sobre un enlace compartido eso no está garantizado. Con archivos de 300 KB la pregunta simplemente no se plantea. **Comprimir es lo que vuelve este riesgo estructuralmente imposible en vez de meramente improbable.**

4. **Almacenamiento duplicado.** El archivo se conserva en **los dos** servidores, hoy sin política de purga. La diferencia entre 3 MB y 400 KB por invitado es la diferencia entre ~200 GB y ~33 GB al año.

> **Nota de contexto:** el Portal y Tlalpan aceptan **ambos exactamente 12 MB** (`max:12288` = `12582912` bytes). No hay desalineación entre los dos topes, y este cambio no debe introducir una. Si en el futuro alguien mueve uno de los dos, se mueven los dos.

---

## Estado actual — verifícalo antes de tocar nada

Todo el flujo vive en `src/modulos/invitados/solicitud/components/PasoDatosInvitados.vue`.

| Pieza | Ubicación | Estado |
|---|---|:---:|
| Input de archivo | línea ~295 | ✅ Existe |
| `setArchivo(archivo)` — **punto único de entrada** | línea ~72 | ✅ Existe |
| Clic en dropzone → `abrirArchivo` → `onArchivoInput` | ~78-79 | ✅ Existe |
| Drag & drop → `onDrop` | ~80 | ✅ Existe |
| Preview con `URL.createObjectURL` | ~75 | ⚠️ Nunca se revoca |
| `fd.append('documento', documentoArchivo.value)` | ~122 | ✅ Existe |
| Validación de longitudes espejo del back (`REGLAS`) | ~40-60 | ✅ Existe — **imítala** |
| Compresión de imagen | — | ❌ **No existe: es tu trabajo** |
| Validación de tamaño en el front | — | ❌ **No existe: es tu trabajo** |

**La suerte estructural de este componente:** los tres caminos de entrada (clic, drop, `change`) convergen en `setArchivo()`. **Hay un solo lugar donde insertar la compresión. Mantenlo así** — no dupliques la lógica por camino.

---

## Restricciones que no se negocian

1. **Cero dependencias nuevas.** Se resuelve con Canvas API nativa en ~60 líneas. `package.json` de este proyecto está deliberadamente curado; no agregues `browser-image-compression` ni librerías de EXIF.

2. **NO toques el `accept="image/*"` ni agregues `capture`.** Tal como está, iOS y Android abren su menú nativo con *Tomar foto / Fototeca / Explorar*. **Los dos escenarios —cámara y archivo existente— ya salen del mismo input.** Poner `capture="environment"` forzaría la cámara y **eliminaría** la opción de subir una foto existente, que es la mitad del caso de uso.

3. **El servidor sigue siendo la autoridad.** Esta validación es para responder sin viaje al servidor y para dar un mensaje mejor, igual que hace `validarLongitudes()`. El back valida de todos modos. Nunca al revés.

4. **Si la compresión falla, avisa — no hagas *fallback* silencioso al original.** El original de 11 MB sería aceptado por ambos lados, así que no se pierde el dato; pero sube lentísimo, arrastra el EXIF con el GPS del invitado y es justo el que puede disparar el *timeout* y el breaker descritos arriba. El usuario debe enterarse y poder reintentar con otra foto.

---

## Lo que hay que construir

### 1. `src/utils/imagen.js` (archivo nuevo)

Sigue el estilo de `src/utils/curp.js`: funciones exportadas con nombre, JSDoc con `@param`/`@returns`, y comentarios que expliquen **por qué**, no qué.

```js
const LADO_MAYOR = 1600   // Suficiente para que el texto de una INE sea legible
const CALIDAD = 0.85      // Por debajo de 0.75 aparecen artefactos en letra chica
const TIPO_SALIDA = 'image/jpeg'
```

**`optimizarImagen(archivo)` → `Promise<File>`**

Pasos, en este orden:

1. **Decodificar respetando EXIF:**
   ```js
   const bitmap = await createImageBitmap(archivo, { imageOrientation: 'from-image' })
   ```
   ⚠️ **`imageOrientation: 'from-image'` no es opcional.** Una foto de celular guarda la rotación en un tag EXIF en lugar de rotar los píxeles, y `canvas.drawImage()` **no** la aplica. Sin esta opción, las identificaciones llegan de lado o de cabeza al escritorio del operador. Es el bug más frecuente de este tipo de flujo y **solo se detecta probando en un teléfono real**, nunca subiendo archivos desde la PC.

2. **Escalar** con `Math.min(1, LADO_MAYOR / Math.max(bitmap.width, bitmap.height))`. Nunca escales hacia arriba: una foto ya pequeña se queda como está.

3. **Dibujar** en un `canvas` de las dimensiones destino (redondeadas) y llamar `bitmap.close()` para liberar memoria.

4. **Codificar** con `canvas.toBlob(resolve, TIPO_SALIDA, CALIDAD)` envuelto en una `Promise`.

5. **Reconstruir un `File`**, no devolver el `Blob`:
   ```js
   new File([blob], nombreConExtensionJpg, { type: TIPO_SALIDA, lastModified: Date.now() })
   ```
   `FormData` necesita nombre y tipo para que Laravel reciba un `UploadedFile` coherente.

**`formatearBytes(bytes)` → `string`** — auxiliar para los mensajes de error ("11.4 MB").

### 2. `PasoDatosInvitados.vue` — cambios

**a) `setArchivo` pasa a ser `async`** y hace: validar tipo → comprimir → validar tamaño → fijar archivo y preview. Mientras corre, un `ref` como `procesandoImagen` debe estar en `true`.

**b) Estado de carga visible.** Comprimir una foto de 12 MP **bloquea el hilo principal entre 200 y 800 ms** en un teléfono de gama media. Sin indicador, la interfaz parece congelada justo después de tocar. Usa el patrón que ya existe: `BaseButton` acepta `loading` y `disabled`, y `agregando` ya se usa así.

**c) Errores por el canal que ya existe.** `validationErrors.value.documento` es un **arreglo de strings** —`documento: ['mensaje']`— porque así llegan los 422 de Laravel y así los pinta la línea ~298. Respeta esa forma exacta.

**d) Corrige la fuga de object URLs.** La línea ~75 crea `URL.createObjectURL` y nunca lo revoca. Revoca el anterior al reemplazar y en `onLimpiar()`. Es un defecto preexistente, pero lo vas a tocar de todos modos.

**e) El preview debe ser del archivo comprimido**, no del original. Además de coherencia, evita mantener el archivo grande vivo en memoria.

**f) Bloquea el envío mientras se comprime.** `onAgregar` no debe poder dispararse con `procesandoImagen === true`.

---

## Casos borde — cada uno necesita su rama

| Caso | Qué pasa sin manejarlo | Comportamiento correcto |
|---|---|---|
| **HEIC/HEIF de iPhone** | `createImageBitmap` lanza excepción. Hoy el preview quedaría en blanco sin ninguna explicación | Mensaje claro: *"No pudimos procesar esta imagen. Intente con una foto en formato JPG o PNG."* |
| **Captura de pantalla PNG chica** | El JPEG re-codificado puede pesar **más** que el original | Si `blob.size >= archivo.size`, **conservar el original** |
| **Foto ya pequeña (< 1600 px)** | Reescalado inútil, pérdida de calidad | Escala 1 — no ampliar. Se re-codifica igual (así se elimina el EXIF) |
| **Archivo que no es imagen** | Llega al servidor y rebota con un 422 tardío | Rechazar en `setArchivo` con el mismo texto que el back: *"El documento debe ser una imagen."* |
| **Resultado aún > 12 MB** | Viaje al servidor para recibir un 422 después de subir 12 MB | Bloquear el envío con el texto exacto del back: *"La imagen no debe exceder 12 MB."* |
| **El usuario elige otro archivo mientras comprime** | Condición de carrera: gana la respuesta más lenta, no la última elección | Descartar resultados obsoletos (contador de petición o token de cancelación) |
| **`createImageBitmap` no existe** (WebView muy viejo) | `TypeError` en un dispositivo que no puedes reproducir | Detectar y degradar: validar solo el tamaño y subir el original, avisando si excede |

---

## Verificación antes de dar por terminado

**Obligatorio en dispositivo real. Este cambio no se puede validar solo desde la PC.**

1. **iPhone — foto tomada en el momento**, en orientación vertical y horizontal. Verifica que la miniatura **y** el archivo final aparezcan derechos. Es la prueba que atrapa el bug de EXIF.
2. **iPhone — foto elegida de la Fototeca.**
3. **Android — cámara y galería.**
4. **PC — arrastrar y soltar**, y clic con el explorador.
5. **PNG pequeño** (captura de pantalla): confirma que **no** creció.
6. **Archivo no-imagen** (un PDF): confirma el mensaje, sin request al servidor.
7. **Confirma la reducción**: registra `archivo.size` antes y después en la consola durante las pruebas. Una foto de 12 MP debe quedar en **250–450 KB**. Si sale por encima de 1 MB, algo no está escalando.
8. **Verifica en el servidor** que el archivo almacenado bajo `storage/app/private/solicitudinvitado/{idSolicitud}/` pesa lo esperado y **abre derecho**.
9. `npm run lint` y `npm run build` sin errores.

> **Sobre pruebas automatizadas:** este proyecto tiene `vitest` configurado pero **no hay ni un solo archivo de prueba** hoy. No establezcas unilateralmente una convención de testing en este cambio. Si quieres proponer pruebas para `optimizarImagen`, dilo al final como recomendación separada.

---

## Lo que NO debes hacer

- ❌ Agregar dependencias a `package.json`
- ❌ Poner `capture` en el input o cambiar `accept="image/*"`
- ❌ Duplicar la lógica de compresión por cada camino de entrada
- ❌ Subir el original en silencio si la compresión falla
- ❌ Convertir a WebP "porque pesa menos" — el operador de Tlalpan abre estos archivos en un escritorio; JPEG es la opción segura
- ❌ Tocar `solicitudStore.js` salvo que encuentres una razón real y la expliques
- ❌ Bajar `CALIDAD` por debajo de 0.75 ni `LADO_MAYOR` por debajo de 1200: el texto de la credencial debe seguir siendo legible

---

## Al terminar, reporta

1. Archivos creados y modificados, con las líneas relevantes
2. Reducción medida en las pruebas reales: peso antes → después, por dispositivo
3. Confirmación explícita de que la **orientación** salió correcta en iPhone vertical y horizontal
4. Qué casos borde probaste y cuáles no pudiste (por falta de dispositivo, por ejemplo) — **dilo, no lo omitas**
5. Cualquier decisión donde te apartaste de este prompt, con el motivo
