Files
apps-registry/workloads/docs-portal/docs/decisiones/0010-pago-envio-reales-y-storefront.md
T
devopsandClaude Sonnet 5 17348e6285 docs(adr-0010): agregar dos bugs reportados al checklist de staging
Importacion manual de imagenes de producto no se refleja en el
storefront (CSV si funciona), y en mobile la visualizacion de imagenes
de producto se corta obligando a scrollear. Ambos reportados por el
usuario 2026-09-20, en investigacion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:17:47 +00:00

26 KiB
Raw Blame History

ADR 0010 — Pago (ePayco PSE) y envío (EnvíoClick) reales; banner y buscador del storefront

Estado: aceptada — ambas integraciones desplegadas y verificadas en vivo (sandbox de cada proveedor); banner dinámico y buscador rediseñado desplegados Fecha: 2026-09-09

Contexto

El ADR 0009 dejó PayU decidido como proveedor de pago de negocio y documentó la necesidad de un módulo custom (AbstractPaymentProvider) porque ningún plugin comunitario de Medusa sirve para la API real de PayU LatAm. Entre esa decisión y la implementación real pasaron dos correcciones más, ninguna prevista en el ADR original — este documento cierra esa cadena y agrega la segunda mitad de "checkout real" que el 0009 dejó pendiente: el envío (EnvíoClick), más dos mejoras de UI del storefront relacionadas (banner dinámico, buscador) pedidas una vez que ambas integraciones ya estaban en producción.

1. Pago — de PayU a Rapyd a ePayco

1.1 PayU LatAm quedó inviable: Rapyd compró ese negocio

Al intentar registrar la cuenta de comercio en developers.payulatam.com (2026-08-29), el registro real redirige a Rapyd — confirmado en vivo contra board.rapyd.net con capturas de pantalla del propio usuario, no solo documentación. Investigación confirmó la causa: Rapyd adquirió el negocio de PayU LatAm/África en 2023. PayU LatAm como entidad separada con la API documentada en el ADR 0009 (apiLogin/apiKey/accountId) ya no es la vía de registro real para un comercio nuevo.

Se investigó y planificó por completo la alternativa Rapyd (API REST moderna, firma HMAC-SHA256, endpoint /v1/payments — arquitectura totalmente distinta a la de PayU LatAm clásico, no una migración menor de credenciales) pero nunca se escribió código: antes de implementar, el usuario planteó ePayco como alternativa y, al evaluarla, resultó preferible por costo/encaje — Rapyd quedó descartado en la etapa de plan, no de código.

1.2 Decisión final: ePayco

Motivo de negocio: ePayco es 100% propiedad de Davivienda desde 2024 — mejor tarifa PSE dado que Davivienda ya es uno de los bancos disponibles del comercio (mismo dato de negocio del ADR 0009 §3: Davivienda, Lulo Bank, Banco de Bogotá).

Hallazgo técnico no obvio, verificado en vivo, no en documentación: ePayco tiene dos productos de integración distintos bajo la misma marca:

  • Un producto nuevo tipo "Apify"/Web-Checkout (widget alojado).
  • La integración real de PSE que efectivamente se usó: la API "restpagos" / secure.payco.co, más antigua, que combina login con bearer token más cifrado AES-CBC campo por campo usando la llave privada del comercio como clave AES con IV en cero — un protocolo poco común que no vale la pena reimplementar a mano. Se optó por depender del paquete oficial epayco-sdk-node en vez de escribir el cliente HTTP propio (a diferencia de EnvíoClick, ver §2).

Nombres de método/campo reales del SDK, corregidos contra su código fuente instalado (no contra su propia documentación pública, que estaba desactualizada respecto al paquete):

  • Lista de bancos PSE: getBanks() (la doc pública sugiere pseBank(), que no existe en el SDK real).
  • Confirmación de método: campo method_confirmation (la doc pública sugiere metodoconfirmacion).

Implementación (commerce-backend/src/modules/epayco-pse/, src/lib/epayco.ts): módulo de pago custom (AbstractPaymentProvider), estado pending_authorization tras iniciar el pago (mecanismo documentado de Medusa para métodos asíncronos — permite que el carrito complete en una orden "esperando confirmación" sin bloquear sobre la respuesta del banco), webhook nativo de Medusa en /hooks/payment/epayco_pse (sin subscriber custom — el payment-webhook + process-payment-workflow incluidos en el core ya completan la orden automáticamente al recibir la notificación).

Verificado en vivo (2026-09):

  • Credenciales reales del comercio (P_CUST_ID_CLIENTE, P_KEY, PUBLIC_KEY, PRIVATE_KEY) cargadas en el secret real del cluster.
  • Llamada real a getBanks() contra la API de ePayco devolvió el listado real de bancos PSE en modo sandbox.
  • El webhook /hooks/payment/epayco_pse es alcanzable desde afuera: POST externo contra https://commerce.cruzcloud.net/hooks/payment/epayco_pse responde 200 OK (la firma inválida se maneja puertas adentro sin romper la respuesta HTTP) — cierra el riesgo de red que el ADR 0009 §2 había señalado como pendiente de validar (mismo patrón de incidente ya documentado para otro servicio, incidente-cloudflared-504-quic-2026-08.md).

Limitaciones conocidas y aceptadas, no bloqueantes:

  • refundPayment tira un error explícito de "no implementado" — el endpoint real de reembolso nunca se verificó en vivo, se prefirió fallar ruidosamente a fingir éxito.
  • El nombre exacto del campo de la URL de redirección del banco en la respuesta de bank.create() se maneja de forma defensiva, probando varios nombres candidatos con un error explícito y el payload completo si ninguno matchea — todavía no se probó con un pago PSE real de principio a fin (solo getBanks() está confirmado en vivo). Ver §3 para cómo cerrar esto desde el storefront real.
  • El comercio está en modo sandbox de ePayco (EPAYCO_ENV: sandbox en el ConfigMap) — no se ha movido dinero real todavía.

2. Envío — EnvíoClick reemplaza la tarifa fija

El ADR 0009 (nota de implementación) dejó una shipping option con tarifa fija de $15.000 COP como placeholder de negocio, anotando explícitamente que un cálculo real por peso/volumen era trabajo futuro — "eso es justamente lo que EnvíoClick resolvería más adelante". Este ADR cierra ese punto.

Hallazgo de negocio, no técnico, que condiciona la precisión real

Ningún producto del catálogo tiene peso ni dimensiones cargados (confirmado contra la base real: 18 productos activos, 0 con weight, 0 con length). Sin este dato, cualquier cotización real de EnvíoClick no puede ser precisa. Se aceptó como tarea de datos pendiente, no de código — la integración usa un paquete de fallback conservador (1 kg, 20×20×20 cm) mientras no haya datos reales por producto, documentado en src/modules/envioclick-fulfillment/types.ts (DEFAULT_PACKAGE).

API real, corregida contra la fuente primaria, no contra un plugin de referencia desactualizado

Un plugin de WordPress usado como primera referencia resultó ser para la API v1 de EnvíoClick, ya obsoleta. La doc real (apidoc.envioclickpro.com.co, generada con apiDoc — se bajó y leyó directo api_data.json/api_project.json, ya que la SPA no renderizaba sin sesión) reveló diferencias reales, confirmadas con una llamada real exitosa:

Plugin de referencia (v1, desactualizado) API real (v2, confirmada en vivo)
Base URL api.envioclickpro.com https://api.envioclickpro.com.co/api/v2
Header de auth AuthorizationKey: <key> Authorization: <key> (sin prefijo)

POST /quotation devolvió en vivo tarifas reales de PIBOX, COORDINADORA, ENVIA, RAPPI y TCC para una cotización de prueba real — se toma flete + minimumInsurance (seguro mínimo obligatorio, hay que sumarlo al flete para el precio real a cobrar) más barato del array data.rates[], mismo criterio de "más económico" que ya usaba el plugin de referencia.

Hallazgo crítico de Medusa, evitado antes de romper nada (no un incidente real)

Leyendo directo @medusajs/utils/dist/common/define-config.js (no documentación): defineConfig() ya registra por defecto Modules.FULFILLMENT con el provider manual_manual, aunque medusa-config.ts no lo mencione explícitamente. transformModules() indexa por serviceName y el último bloque que se declara pisa, no suma, al anterior. Agregar un bloque de fulfillment propio sin incluir también @medusajs/medusa/fulfillment-manual adentro habría roto el fulfillment manual existente en silencio — mismo tipo de riesgo que casi pasó con payment en el ADR 0009, evitado leyendo el código fuente real antes de escribir el bloque nuevo (ambos providers conviven hoy en el mismo bloque de medusa-config.ts).

Implementación (commerce-backend/src/modules/envioclick-fulfillment/, src/lib/envioclick.ts): AbstractFulfillmentProviderService, calculatePrice() invocado en vivo cada vez que el carrito refresca shipping options (Medusa advierte que si este método falla, bloquea el checkout — el fallback conservador de arriba existe también por esto, no solo por falta de datos). createFulfillment() genera la guía real vía /shipment (o /shipment_sandbox en modo prueba — el único endpoint con sandbox real, la cotización siempre usa tarifas reales). Sin cambios en el storefront: GET /store/shipping-options nativo de Medusa ya invocaba calculatePrice() automáticamente para cualquier shipping option price_type: "calculated".

Verificado en vivo (2026-09):

  • Shipping option "Envío nacional estándar" migrada de manual_manual/ tarifa fija a envioclick_standard/calculated vía Admin API (con usuario admin temporal, eliminado después de usarlo).
  • Un 400 inicial ("Providers (envioclick_standard) are not enabled for the service location") se resolvió vinculando el provider a la stock location (POST /admin/stock-locations/:id/fulfillment-providers) — Medusa exige esto además del bloque de módulo.
  • Carrito de prueba real con dirección → shipping_total: 8941 COP, tarifa real de EnvíoClick, no el fijo de $15.000.
  • Datos reales de la bodega de origen cargados (dirección Cra 100 #42-65, contacto [email protected] / 3014750823) tanto en el ConfigMap (ENVIOCLICK_ORIGIN_*) como en el registro real de la stock location en Medusa (antes: "Sin dirección física registrada").
  • El comercio está en modo sandbox de EnvíoClick (ENVIOCLICK_SANDBOX: "true") — las guías que se generan son de prueba, sin costo; ninguna cotización es de prueba (no existe sandbox de cotización).

Limitaciones conocidas y aceptadas, no bloqueantes:

  • createReturnFulfillment tira "no implementado" — devoluciones fuera de alcance.
  • Cancelación de guía real (POST cancellation/batch/order, hasta 50 por lote, usando los idOrder que devuelve /shipment) confirmada como existente en la API pero no implementada en el módulo — se descubrió durante la investigación, no se integró por no ser parte del flujo de checkout mínimo.
  • Solo EnvíoClick como provider — no hay selección de múltiples couriers por el comprador (v1 siempre toma el más barato).
  • Ningún producto tiene peso/dimensiones reales — ver arriba.

3. Storefront — banner dinámico y buscador

Con ambas integraciones ya en producción, se atendieron dos pedidos de UI del storefront relacionados con la experiencia de compra real:

3.1 Banner dinámico (components/home/hero-carousel.tsx)

Antes: tres slides con copy e imágenes fijas, hardcodeadas en el componente, sin relación con el catálogo real. Ahora: HomePage (app/page.tsx, que ya hace getProducts() del backend real) le pasa al carrusel los productos marcados como featured en el admin (o, si no hay ninguno, los mismos "tesoros" —nuevos o destacados— que ya usa la sección de abajo, para no duplicar el criterio de selección); el carrusel arma cada slide con la imagen, nombre, precio (priceLabel) y link reales del producto, calculando el badge de descuento (% OFF) cuando compareAtPrice existe. Si la tienda no tuviera ningún producto activo (caso borde), cae a un slide estático de respaldo para no romper la página. Autoplay, dots de paginación y animación quedan igual — solo cambia el origen de los datos.

3.2 Buscador (components/layout/navbar.tsx)

El header en mobile competía el buscador con el logo y los tres iconos de acción en una sola fila de grid (grid-cols-[auto_1fr_auto]), quedando visualmente recortado — reportado por el usuario junto a una captura de referencia del header mobile de Best Buy (fila de iconos arriba, buscador a todo el ancho debajo, con micrófono y escaneo QR inline).

Decisión: adoptar el mismo patrón de dos filas en mobile/tablet (logo + iconos arriba, buscador a todo el ancho debajo) pero sin los íconos de micrófono/QR — no hay búsqueda por voz ni por código de barras implementada, y un ícono sin función real confunde más de lo que ayuda. En desktop (lg: y arriba) se mantuvo la fila única original del grid [auto_1fr_auto]: ahí ya sobraba espacio horizontal y partirla en dos solo alargaría el header sin necesidad. Sin cambios en la lógica de PredictiveSearch (autocompletado, debounce, navegación) — solo se reubicó su contenedor según el breakpoint.

Opciones consideradas (pago)

Opción A favor En contra
PayU LatAm (ADR 0009) Ya decidido, credenciales bien documentadas Registro real redirige a Rapyd — la entidad "PayU LatAm" independiente ya no existe para un comercio nuevo
Rapyd Dueño real del negocio ex-PayU LatAm, API moderna y bien documentada API completamente distinta (HMAC, REST moderno) — no es una migración de credenciales, es una integración nueva desde cero; descartada en fase de plan por costo/encaje frente a ePayco
ePayco — elegido 100% Davivienda desde 2024, mejor tarifa PSE con el banco ya disponible del comercio; SDK oficial cubre el protocolo inusual (login + cifrado AES por campo) Dos productos bajo la misma marca (Apify vs. la API real usada) fáciles de confundir; doc pública desactualizada respecto al SDK real; reembolsos no implementados

Consecuencias

  • Checkout real de punta a punta (pago + envío calculado) queda desplegado y verificado con navegador real hasta la redirección al banco de PSE (ver nota de implementación 2026-09-10 abajo), pero ambos proveedores siguen en modo sandbox — no se ha completado ni un pago real ni un envío real todavía, y el login bancario de PSE tampoco se completó (falta credencial de prueba). Ver checklist ampliado abajo.
  • La precisión de la tarifa de envío depende de cargar peso/dimensiones reales por producto — deuda de datos explícita, no de código; candidato natural a extender el importador CSV existente con columnas nuevas.
  • Reembolsos (ePayco) y devoluciones/cancelación de guía (EnvíoClick) quedan fuera de alcance — fallan explícitamente en vez de fingir éxito.
  • Revertir el pago implica deshabilitar el provider epayco_pse en la región y volver al placeholder anterior; revertir el envío implica volver la shipping option a manual_manual/tarifa fija — ninguna de las dos requiere cambios de esquema de datos para revertirse.
  • El banner y el buscador no introducen dependencias nuevas ni cambian ninguna ruta de API — son cambios de storefront puros, reversibles cambiando los dos componentes de vuelta a su versión anterior.

Checklist de pruebas reales desde el storefront (pendiente, no ejecutado en este ADR)

Todo lo de arriba se verificó primero a nivel API/backend con llamadas directas o carritos de prueba armados por API, sin manejar el storefront real como lo haría un comprador. Esa parte del checklist se cerró el 2026-09-10 (ver nota de implementación abajo) usando un navegador real de verdad, no curl.

RESUELTO (2026-09-10) — navegador real vía Docker en la NAS

El shell donde corren los comandos de esta sesión tiene su propia red restringida para npm (un proxy interno que rompe algunas descargas de paquetes — no relacionado con la red real del lab). Docker en la NAS sí sale a internet real, sin ese filtro — confirmado en vivo (docker pull normal, un contenedor de Node golpeando registry.npmjs.org sin problema). Se armó un harness reutilizable con la imagen oficial mcr.microsoft.com/playwright:v1.51.1-jammy (Chromium real) corrida puntualmente contra shop.cruzcloud.net, guardado en .browser-scratch/ en la raíz del lab (checks.mjs + README.md con el comando exacto para reactivarlo — no hace falta ningún contenedor corriendo de forma persistente, la imagen y las dependencias ya quedan cacheadas localmente en la NAS).

Hallazgo no obvio: el daemon de Docker de la NAS ve un filesystem distinto al que ve el shell de esta sesión (que corre en su propio sandbox, aunque monte los mismos repos git) — un volumen -v apuntando a un path de /tmp del shell no tira error pero monta un directorio vacío del lado del contenedor. Hay que usar un path bajo /media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/ (montaje real compartido) para que el contenedor vea los archivos de verdad.

Resultados, con capturas reales:

  1. Pago PSE completo — FUNCIONA. Carrito → dirección Bogotá → envío calculado → banco PSE (sandbox) → la redirección real funcionó: el nombre de campo manejado a la defensiva en bank.create()/initiatePayment matcheó al primer intento, y el navegador llegó a la página real de autorización de PSE (registro.desarrollo.pse.com.co, ambiente de desarrollo/sandbox de PSE Colombia). No se completó el login bancario (requiere credenciales de prueba específicas de PSE que no se tenían a mano) — sigue pendiente confirmar el webhook con un pago sandbox completado de principio a fin (ver checklist nuevo abajo).
  2. Envío calculado real — FUNCIONA. El paso de pago mostró Envío $8.941 (mismo valor ya visto en la verificación por API de la sección anterior) en vez del fijo de $15.000, para una dirección real de Bogotá.
  3. Banner dinámico — funciona, pero destapó un bug real: un producto marcado featured: true pero sin stock (available: false, "Toy Story 5 - Rex") salía primero en el carrusel con botón "Comprar ahora" hacia una página donde en realidad no se puede comprar. Corregido en el mismo commit que este hallazgo (filtro adicional && product.available en app/page.tsx).
  4. Buscador mobile — funciona. El buscador ya no se ve recortado, y el autocompletado despliega bien debajo de la fila nueva a todo el ancho (probado con el viewport de un iPhone 13 real, no devtools).
  5. Bug adicional encontrado, no buscado: el sandbox real de ePayco (bank.getBanks()) devuelve un primer elemento placeholder (bankCode: "0", "A continuación seleccione su banco..." con el texto mal decodificado — unicode escapado literal) mezclado con los bancos reales, mostrado como una opción más, seleccionable, en el <select> del checkout. Ningún banco PSE real tiene código "0" — se filtra explícitamente en pse-banks/route.ts.

Ampliado 2026-09-10 con el resto de la superficie real del storefront (4 páginas + components/{account,cart,catalog,checkout,home,layout, product,search,wishlist}), agrupado por riesgo, y ejecutado el mismo día contra shop.cruzcloud.net con el harness de .browser-scratch/.

RESUELTO (2026-09-11) — segunda tanda con navegador real

Resultados reales, uno por uno (código de cada prueba en checks.mjs):

  • /checkout con carrito vacío (ítem 9) — OK. Muestra "Tu carrito está vacío" con link a /catalog, no rompe.
  • Fallback "Confirmar por WhatsApp" (ítem 10) — OK. Abre api.whatsapp.com/send?phone=... en pestaña nueva con el mensaje del pedido armado correctamente.
  • Filtros de /catalog (ítem 11) — OK. Un filtro de categoría real ("Figuras de acción") redujo los productos mostrados de 18 a 4. Nota: estos filtros (categoría/marca/edad dentro de la barra lateral) son estado de cliente, no reflejado en la URL — distinto de los filtros por query param (?category=...) que sí usa la navegación del header. Ninguno de los dos es un bug, son dos mecanismos distintos coexistiendo; queda como nota para no confundirlos.
  • Búsqueda sin resultados (ítem 12) — BUG real encontrado y corregido. El dropdown de autocompletado no mostraba absolutamente nada (ni "Buscando…" ni ningún mensaje) cuando una búsqueda de 2+ caracteres no tenía coincidencias — el usuario se quedaba sin feedback de que su búsqueda había corrido. Corregido en predictive-search.tsx: ahora muestra "No encontramos resultados para…" con el botón de buscar en todo el catálogo.
  • Slug de producto inválido (ítem 14) — hallazgo, no corregido todavía. La página sí muestra el contenido correcto de "Este tesoro no existe" (el código ya llama a notFound() de next/navigation, como corresponde), pero la respuesta HTTP real es 200, no 404 — confirmado independientemente con curl, no es un artefacto de Playwright. No se investigó a fondo en qué capa se pierde el status real (Next.js standalone vs. alguna capa de Cloudflare/NPM/Traefik en el medio, ver incidente-cloudflared-504-quic-2026-08.md para precedente de problemas de red en esa cadena) — bajo impacto en la experiencia del usuario (el contenido es correcto), pero sí afecta SEO/crawlers. Queda como pendiente de investigar, no de corregir a ciegas.
  • Zoom interactivo del producto (ítem 15) — OK. Hover-zoom y el modal de click-to-zoom siguen funcionando igual tras los cambios recientes al storefront.
  • Wishlist/favoritos (ítem 17) — OK. Agregar a favoritos + recargar la página: persiste correctamente vía localStorage.
  • Otros viewports, tablet y desktop grande (ítem 19) — OK. Capturas guardadas, sin problemas de layout visibles en el banner ni el header en esos anchos.
  • Página 404 genérica (ítem 20) — OK. Una ruta que no existe cae en not-found.tsx con status HTTP 404 real (a diferencia del caso de producto inválido arriba — la diferencia de comportamiento entre ambos casos es justamente lo que quedó como pendiente de investigar).
  • Accesibilidad básica (ítem 21) — OK, parcial. El buscador es alcanzable navegando solo con Tab desde el body, sin mouse. Falta probar el resto del flujo de checkout por teclado.
  • Registro/login con email y contraseña (parte del ítem 16) — OK. Crear cuenta con nombre/apellido/email/contraseña funciona y deja la sesión iniciada. La parte de Google OAuth sigue sin probarse (necesita una cuenta de Google real, no algo que se pueda automatizar sin credenciales).

Pendiente — lo que queda sin probar

  1. Resiliencia del fallback de envío: forzar una dirección o producto que haga fallar la cotización real de EnvíoClick (ej. un DANE code inválido) y confirmar que el checkout sigue avanzando con el precio de respaldo ($15.000) en vez de romperse — comportamiento documentado en calculatePrice(), nunca ejercitado con un fallo real.
  2. Webhook de ePayco con un pago real de sandbox completado: conseguir credenciales de prueba reales de PSE (persona/clave de algún banco del ambiente desarrollo), completar el login bancario de punta a punta, y confirmar que el webhook mueve la orden a completada sin intervención manual.
  3. Pago cancelado o rechazado en el banco PSE: confirmar que la orden no queda en un estado ambiguo ("colgada" en pending_authorization para siempre).
  4. Persona jurídica + NIT en el formulario de PSE — solo se probó persona natural con cédula.
  5. Selección de variantes (cuando un producto tiene más de una) y que el precio/disponibilidad mostrado cambie según la variante elegida — ningún producto probado hasta ahora tenía más de una variante real. 14b. Investigar por qué /product/<slug-inválido> responde HTTP 200 en vez de 404 pese a llamar notFound() correctamente (ver hallazgo arriba) — identificar la capa responsable antes de tocar código. 16b. Login con Google (OAuth completo) incluyendo /auth/google/callback — necesita una cuenta de Google real de prueba.
  6. Bug conocido de Firefox Android (ver memoria de proyecto, known-bug-firefox-android-layout) — Playwright puede emular Android/Chrome pero no Firefox; hace falta playwright-firefox o un dispositivo real. 21b. Accesibilidad del checkout completo por teclado (no solo el buscador) — tab order y foco visible en los 3 pasos.
  7. Importación manual de imágenes no se refleja en el storefront (reportado por el usuario 2026-09-20): subir/editar imágenes de un producto a mano (vs. importación por CSV, que sí funciona) no aparece del lado del storefront. Falta reproducir contra el flujo real de importación manual del admin de Medusa (ver temp-admin/ari-shopping-tienda en memoria) y confirmar si es caché (ISR de Next, force-dynamic ya está en la página de producto — ver más arriba), staleness de Meilisearch (ver memoria search-inventory-staleness, mismo síntoma de "el catálogo directo sí, la búsqueda no") o algo específico del pipeline de importación en commerce-backend.
  8. En mobile, la visualización de imágenes de producto se corta y obliga a scrollear (reportado por el usuario 2026-09-20, con captura real de shop.cruzcloud.net en un producto con múltiples fotos). Toda la clase de bug de overflow por falta de min-w-0 ya se revisó en vivo en esta sesión y sigue presente en product-gallery.tsx y app/product/[slug]/page.tsx — sospecha sin confirmar: el modal "Zoom interactivo" (ProductGallery/DialogContent, min-h-[70vh] + overflow-hidden) en viewport móvil real, donde la barra de direcciones de Chrome/Safari hace que 100vh mida más que el área visible real. Pendiente reproducir con un dispositivo real o emulación de visualViewport (Playwright con viewport móvil no replica el comportamiento dinámico de la barra de direcciones).