Medido en vivo (run 296): en la imagen del backend el análisis de capas
toma ~14 min en el runner del NAS (descarga de DB 8 s, detección 9 s); con
15m hizo timeout una vez (run 294). No era el secret-scan. Solo este paso:
el HIGH reusa el análisis cacheado (~2 s) y frontend/docs tardan ~2 min.
No cambia qué se revisa ni que CRITICAL bloquea.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
El escaneo de secretos que Trivy hace por defecto sobre toda la imagen
(node_modules incluido) hizo timeout (context deadline exceeded, 15m) en
el gate CRITICAL del backend el 2026-09-27; el propio log de Trivy
recomienda --scanners vuln. Aplicado a los 6 pasos trivy image (CRITICAL
bloqueante y HIGH informativo — un FATAL por timeout tumba el job aunque
--exit-code sea 0) de build.yaml, build-medusa.yaml y deploy-docs.yaml.
Sin pérdida real de cobertura (auditado por devsecops-auditor): Gitleaks
ya escanea todo el árbol de cada workload antes del build (needs:
gitleaks), ningún build recibe --build-arg/--secret con credenciales, los
ENV build-only del backend no llegan al stage final, y 11 corridas
recientes de image secret-scan tuvieron 0 hallazgos. Hueco residual
aceptado: secretos en capas de imágenes base oficiales.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Preparación para renombrar las categorías reales con tildes/eñes
("Figuras de acción", "Muñecas y peluches", etc.):
- Importador CSV (admin): categorías y colecciones se matchean ignorando
tildes/eñes, para que un CSV escrito sin tildes reuse la categoría
existente en vez de crear un duplicado. Títulos de producto sin cambios.
- Catálogo: ?category= se resuelve contra los nombres reales ignorando
tildes/mayúsculas, así los links viejos siguen funcionando.
- Plantilla CSV de ejemplo con "Figuras de acción".
Aprobado por devsecops-auditor (sin colisiones con los nombres actuales,
sin riesgo de inyección/XSS). CI guard local OK, tsc backend OK, build de
producción del storefront OK.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
El paso "Validar navegación real del catálogo" buscaba la key exacta
`${category}:${collection}:${query}` en app/catalog/page.tsx; desde
franquicias/personajes la key también incluye franquicia/saga/personaje y
el pipeline del frontend fallaba (2 veces) aunque el comportamiento
protegido se cumple. Ahora valida por prefijo + exige los parámetros de
taxonomía. No toca ningún gate de seguridad.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Backend (commerce-backend):
- Módulo product-review (tabla nueva product_review; migración generada con
medusa db:generate, no toca tablas existentes).
- GET /store/reviews?product_id (solo aprobadas), /store/reviews/summary,
GET /store/reviews/me y POST /store/reviews con auth de cliente: exige que
el pedido sea del cliente, no esté cancelado y contenga el producto
("Compra verificada"); 1 reseña por cliente y producto; nace "pending".
- /admin/reviews (+ /:id POST status, DELETE) y página "Reseñas" en el admin
para aprobar/rechazar/borrar.
Storefront (ecommerce):
- /cuenta/pedidos ("Mis pedidos"): historial real + diálogo "Califica tu
compra" (radiogroup accesible, flechas de teclado).
- Ficha: sección de reseñas con "Compra verificada" y estrellas junto al
título; tarjetas: promedio y cantidad (una llamada /api/reviews/summary).
- Accesos: "Mis pedidos y reseñas" en la cuenta y en Ayuda.
Validado en entorno descartable (Postgres/Redis/Meilisearch/Medusa propios):
E2E de API 20/20 (incluye pedido interno por draft order, cancelación y
borrado), E2E de tienda con navegador 15/15, admin "Reseñas" verificado en
build de producción. Revisado por frontend-critic (contraste de estrellas
amber-600, patrón radiogroup).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Taxonomía desde tags de Medusa con prefijo (franquicia:/saga:/personaje:),
muchos-a-muchos (ej. "Minnie y Mickey" tiene dos personajes). Pixar va
dentro de Disney.
- lib/taxonomy.ts: helpers + árbol franquicia > saga > personaje.
- Navbar: "Personajes" pasa a mega panel por franquicia (con totales,
sagas y personajes); el menú lateral mobile muestra franquicias y sagas.
Fallback a colecciones si el provider no trae tags.
- Catálogo: filtros "Franquicia" y "Personaje" (acotado a la franquicia),
chip removible de "Saga", ?franquicia/?saga/?personaje en la URL, título
según la taxonomía de la URL, búsqueda normalizada ("spiderman" encuentra
"Spider-Man"). Conteos client-side (no son facetas de Meilisearch).
- allowedCategories memoizado (elimina warnings exhaustive-deps).
Revisado por frontend-critic (contraste de personajes /80, H1 solo desde la
URL, Escape devuelve el foco al trigger) + Playwright: check-taxonomy
17/17 + extras, shot-navbar sin regresiones.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
- Desktop xl+: las 6 categorías raíz directo en la barra con desplegable
de subcategorías; lg-xl mantiene "Departamentos"; "Personajes"
(colecciones) y "Ayuda" a la derecha; hamburguesa solo en mobile.
- "Enviar a <nombre> · <ciudad>" (location-store persistido) que precarga
departamento/ciudad en el checkout; "Hola X / Mi cuenta" y "Cambios y
devoluciones" en xl+.
- Header compacto al hacer scroll en lg+ (histéresis 160/40px).
- Fix de menús (también en DepartmentsMegaMenu, ya presente en prod): el
click de mouse ya no cierra el menú recién abierto por hover
(keepOpenAfterHover) y el hover solo reacciona a pointerType "mouse",
así el mouseleave sintético de touch no autocierra en tablets.
Revisado por el subagente frontend-critic (3 hallazgos, todos corregidos y
re-verificados) + Playwright contra build de producción local: sin solapes
a 1024/1280/1440/1920, click real, touch, mobile 390/320 sin cambios.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
- object-contain en vez de object-cover: las fotos secundarias no son
cuadradas (Buzz 933x1500, Tristeza 896x1052) y cover les cortaba
cabeza/pies. Mismo criterio que product-gallery.tsx.
- unoptimized desde la 2da imagen: pasar las ~58 imagenes de las galerias
por el optimizador de Next dejo un pod de prod OOMKilled (768Mi) ante una
carga en rafaga. Originales ~165 KB promedio, lazy; la 1ra imagen sigue
optimizada como antes de la galeria.
Validado con build de produccion local y fotos reales (Playwright: fit
contain medido, fuentes optimizada/directa, suite de galeria completa).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Permite ver todas las imágenes de un producto desde el catálogo y los
carruseles sin entrar a la ficha: swipe con scroll-snap nativo en
móvil, flechas al pasar el mouse en escritorio (pointer-fine) y puntos
de posición. Cada imagen sigue enlazando a la ficha; productos con una
sola imagen no muestran controles. overscroll-behavior-x: contain evita
que el swipe de imágenes mueva el carrusel de productos del inicio.
Validado antes del merge con build de producción local (datos mock) y
Playwright con eventos táctiles reales a 390/320 px y 1280 px.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
El segundo reinicio real del NAS confirmo que /etc/sysctl.d/99-disable-ipv6.conf
se aplica al arrancar (disable_ipv6=1 sin sysctl -p manual). Se cierran
todos los pendientes del playbook y se reescribe la lista sin sintaxis de
tasklist/tachado, que el portal renderizaba literal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
La prueba de reinicio del NAS (2026-09-26) mostro que /etc/sysctl.conf
persiste pero ZimaOS no lo aplica al arrancar (systemd-sysctl solo lee
/etc/sysctl.d/*.conf, sin symlink 99-sysctl.conf). Se documenta la causa,
el fix con /etc/sysctl.d/99-disable-ipv6.conf, como aplicarlo en caliente
con un contenedor privilegiado de un solo uso, y se cierran los pendientes
ya resueltos (commit de entrypoint.sh, limpieza de etiquetas).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Documenta la cadena de causas encontradas tras eliminar tmux-host por
"no toma las sesiones remotas": volumenes de auth faltantes al recrear,
set -e matando al supervisor del healthcheck, IPv6 roto en el NAS
(timeout a Anthropic) y, como causa original, el token OAuth vencido.
Agrega la fila de expiracion OAuth a "Que sobrevive a que" en
sesion-tmux-persistente.md con enlace cruzado al playbook.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
Nueva fase, separada y con prioridad sobre las Fases 2-4 del ADR 0012:
practica de Apps Script (via clasp, sin MCP -- se verifico que no existe
uno oficial de Google) y AppSheet (documentado desde afuera, sin export
de codigo posible, verificado contra la documentacion oficial) para
iniciativas reales de la empresa con cuenta personal, sin datos ni
credenciales de cliente en ningun repo/doc. Deja explicito lo que queda
en pausa mientras esta fase este activa: swap de tmux-host, Fase 4 del
ADR 0012 (GKE), prueba del skill frontend-design.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
El MCP self-hosted (taylorwilsdon/google_workspace_mcp) quedo abandonado:
limite real en FastMCP's OAuthProxy, no error de configuracion -
redirect_uri se arma desde base_url (no desde GOOGLE_OAUTH_REDIRECT_URI),
y base_url tambien alimenta issuer_url, que mcp/server/auth/routes.py
exige HTTPS-o-localhost sin bypass. Documenta la causa raiz confirmada
leyendo el codigo fuente instalado, la decision del usuario de usar
Connectors nativos en su lugar, y la verificacion en vivo (3 calendarios
reales, razonamiento de fechas correcto).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
Agrega el step "Promover a Produccion (CD)" al mismo job que ya
despliega a Staging en build.yaml -- misma logica de
scripts/promote-to-production.sh (sed/perl sobre el manifest de
Produccion) pero sin el read -rp interactivo, gateado por la misma
verificacion de promocion segura (merge-base --is-ancestor) que ya usa
el step de Staging.
Decision explicita del usuario 2026-09-20: cero gate humano entre
Staging y Produccion -- la promocion manual deja de ser el camino
normal, scripts/promote-to-production.sh queda solo como herramienta
de excepcion/rollback.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
medusaFetch() usaba next:{revalidate:60,tags:["medusa-products"]}, cache
en memoria por-pod que sobrevivia a force-dynamic en las paginas que lo
consumen. Con frontend-deploy en 2 replicas sin cacheHandler compartido,
el POST de /api/revalidate del subscriber de Medusa solo llegaba a uno
de los dos pods, dejando al otro sirviendo datos viejos hasta que el TTL
expirara solo -- reportado por el usuario: ediciones manuales de imagen
no se reflejaban en el storefront, pero la importacion por CSV (mucho
mas lenta) si, porque el TTL tenia tiempo de sobra para expirar en
ambos pods.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
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
Agrega tres gotchas nuevos a sesion-tmux-persistente.md, mismo estilo
que los existentes:
- Remote Control nunca se activaba solo: faltaba el flag
--remote-control en launch_claude_in_session().
- Cada recreacion empezaba una conversacion en blanco: se agrego
--continue al comando de lanzamiento.
- Actualizar tmux-host en caliente mata al proceso que hace el swap
(corre dentro del propio contenedor): documentado el workaround de
contenedor sibling via docker.sock.
Tambien actualiza la tabla "Que sobrevive a que" con una columna de
continuidad de conversacion, y el comando citado en la seccion de
healthcheck automatico.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
Tras un corte de luz real, la sesion tmux "lab" se recreaba sola pero
Claude Code nunca arrancaba adentro -- distinto al caveat de persistencia
de sesion ya documentado (ese es "el proceso muere, el contenedor sigue
vivo"; esto es "el host completo se apaga y arranca de cero"). Documenta
las dos causas encontradas (claude nunca se lanzaba tras recrear la
sesion; riesgo de carrera con el mount de /media/HDD-Storage) y el fix
aplicado en scripts/tmux-host/healthcheck.sh.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
El importador de commerce-backend definia normalizeTitleForMatch (colapsa
guion largo/comillas tipograficas) pero nunca la usaba en el matching de
producto/categoria/coleccion -- seguia comparando con toLowerCase().trim()
plano, por eso un re-import de "Toy Story 5 - Woody" con guion largo pegado
desde Excel volvio a crear un duplicado en vez de actualizar el existente.
En el storefront, cualquier slug de producto inexistente devolvia HTTP 200
en vez de 404 (confirmado con un slug inventado): el Suspense del navbar
hace que el layout empiece a stream-ear la respuesta con status 200 antes
de que notFound() se ejecute dentro de la pagina. Se mueve la verificacion
a generateMetadata, que se resuelve completo antes de iniciar el stream.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Documenta el ambiente de Staging (staging.cruzcloud.net, promoción
manual, footer con version real), y agrega a notas-sueltas la
correccion de bodega Cali, redes sociales reales, rediseño de galeria
estilo Amazon, la leccion de min-w-0 en CSS Grid, y la remocion
definitiva de la barra flotante de carrito en mobile. De paso, suma a
la nav el ADR 0010 que existía en el repo pero nunca se había
enlazado.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Incluye: footer con version real via APP_VERSION, y remocion de la
barra flotante de "Anadir al carrito" en mobile -- ambos verificados
en Staging antes de promover.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
El footer mostraba "v4.1" fijo -- inutil para distinguir a simple
vista si Staging y Produccion estan en la misma version. Ahora lee
process.env.APP_VERSION, que el propio CD (.gitea/workflows/build.yaml)
bumpea junto con el tag de la imagen en cada auto-deploy a Staging.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Pedido del usuario con capturas reales: la barra fija seguía
superponiéndose sobre contenido de abajo al scrollear (ej. sobre la
tarjeta de "También te puede interesar"), incluso después de varias
vueltas ajustando cuándo debía aparecer/ocultarse. En vez de seguir
parchando esa lógica, se saca la barra flotante por completo -- queda
un solo botón "Añadir al carrito", visible en su lugar normal dentro
de la página también en mobile (sin duplicar), igual que la mayoría de
tiendas reales.
Se borra components/product/mobile-add-to-cart.tsx (ya sin uso) y el
id="product-buy-button"/id="recommendations-section" que solo existían
para el IntersectionObserver de ese componente.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Decisión del usuario 2026-09-14: dejar de validar cambios directo
contra shop.cruzcloud.net (producción real).
- workloads/ecommerce-staging/: overlay del frontend, mismo Dockerfile
e imagen que Producción, backend compartido (Medusa/Postgres/
Meilisearch de "ecommerce" via FQDN cross-namespace
medusa-svc.ecommerce.svc.cluster.local -- decisión del usuario, evita
duplicar el consumo de la NAS con un backend propio para Staging).
1 réplica en vez de 2, resources mas chicos.
- apps/ecommerce-staging-app.yaml + ecommerce-staging-governance-app.yaml:
Applications de Argo CD para el workload y la governance (en
platform-infra), mismo patrón que ecommerce-app/ecommerce-governance.
- Ingress en staging.cruzcloud.net -- cubierto por el wildcard DNS
*.cruzcloud.net que ya existe en Cloudflare, sin cambios de DNS.
- .gitea/workflows/build.yaml: MANIFEST_FILE/KUSTOMIZE_DIR pasan a
apuntar a Staging -- todo push a main promueve ahí automáticamente
desde ahora. Promover a Producción (workloads/ecommerce, sin cambios
en este commit) queda como acción manual aparte, ver
scripts/promote-to-production.sh.
Secrets commerce-storefront y gitea-registry-secret ya copiados a mano
al namespace ecommerce-staging (no versionados, mismo criterio que el
resto de los secrets reales del repo).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- Imagen principal del producto pasa de object-cover a object-contain
(mismo motivo que las miniaturas del commit anterior): la foto
vertical de Buzz Lightyear (933x1500) aparecía sin cabeza al
seleccionarla, reportado en vivo con captura real.
- Descripción del producto baja de 16px a 14px (text-sm leading-6,
mismo tamaño que los párrafos secundarios de /envios y /cambios) --
seguía viéndose grande al lado del resto de la columna.
- El buy-box mostraba los mismos 2 productos que ya aparecían en el
carrusel "También te puede gustar" de abajo (ambos usaban el mismo
cálculo de categoría/colección). Ahora el buy-box sugiere 1 producto
de una categoría DISTINTA -- cross-sell real en vez de duplicado.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Bug real encontrado armando la prueba automatizada que valida el
commit anterior (cd72962): condicionar el ocultamiento a que el borde
superior de "También te puede gustar" cruce el TOPE del viewport
(top <= 0) no es alcanzable en páginas sin suficiente contenido/footer
debajo -- el scroll máximo del documento nunca llega a ese punto, y el
bug original (barra pegada hasta el fondo) seguía reproduciéndose en
la práctica, aunque el código "parecía" correcto.
Se compara ahora contra la ALTURA del viewport (top < innerHeight): la
barra se oculta apenas la sección empieza a asomar desde abajo, y
sigue oculta el resto del scroll sin depender de cuánto contenido haya
más abajo.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- Miniaturas pasan de object-cover a object-contain: fotos con
relación de aspecto muy vertical (confirmado en vivo: 933x1500 en
este catálogo) perdían cabeza o pies al forzar el recorte cuadrado.
Reportado por el usuario viendo las miniaturas de un producto real.
- La descripción del producto bajaba de 18px (text-lg, mismo tamaño
que un párrafo de apertura de página completa como /envios) a 16px
(text-base) -- se veía desproporcionada al lado del resto de la
columna de compra en el layout de 3 columnas.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
La barra de "Añadir al carrito" quedaba pegada abajo en mobile hasta
el final de la página (incluso sobre el footer), mucho después de que
el usuario ya pasó "También te puede gustar". Se agrega un tercer
sentinel (#recommendations-section) observado por IntersectionObserver:
una vez que su borde superior cruza el tope del viewport, la barra se
oculta y no vuelve a aparecer bajando más.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Root cause real del bug reportado por el usuario (imagen de producto
viéndose gigante/cortada en Chrome y Edge Android) -- no era la imagen:
al mover "También te puede gustar" DENTRO del grid de la página (para
el layout de 2 filas del commit anterior), su carrusel horizontal
(overflow-x-auto interno, sin flex-wrap) empezó a contar como item de
ese grid. Sin min-w-0, el ancho mínimo intrínseco de TODAS las cards
del carrusel sin wrappear se usa para dimensionar la columna
compartida del grid -- en mobile (grid de una sola columna), eso
empujaba TODO (imagen, título, specs) a ~800px de ancho en un viewport
real de ~380px. El navegador termina "zoomeando" el layout para que
entre, dando la sensación de que todo se ve a tamaño real / cortado.
Confirmado en vivo con bisección real: sin el fix, el grid medía 783px
en un viewport de 380px; agregando min-w-0 al contenedor del carrusel
vuelve a 380px. Se agrega min-w-0 también a los otros 3 contenedores
del grid (galería, info, buy-box) y a la tira de miniaturas dentro de
ProductGallery (mismo patrón de overflow-x-auto) como defensa
preventiva -- práctica estándar para items de grid/flex con scroll
horizontal interno.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Reportado en vivo por el usuario: la foto principal (y el modal de
zoom interactivo) se ven borrosas en mobile, especificamente en
Chrome y Edge de Android.
Investigado a fondo antes de tocar nada: no es un archivo corrupto ni
un problema de nuestro cache/backend -- confirmado con
fetch()+createImageBitmap() (que evita <img>/srcset por completo) que
el archivo real decodifica perfecto a resolución completa (1200x1200).
El bug está en cómo Chromium elige el candidato del srcset con
sizes="100vw" + fill a DPR alto (2.6+, cualquier Android real):
currentSrc reporta la URL de un candidato grande, pero
naturalWidth/Height terminan reflejando un candidato mucho más chico
-- reproducido igual en Chromium headless, y consistente con lo
reportado en Chrome y Edge Android reales (mismo motor).
No hay forma de arreglar el algoritmo de selección de Chromium desde
acá. La foto principal y el modal de zoom pasan a unoptimized: al
quedar un solo recurso real por foto (sin candidatos de por medio),
no hay entre qué elegir mal. Costo: la foto pesa el archivo completo
en vez de una versión redimensionada -- aceptable para dos imágenes
puntuales donde la nitidez importa.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
El buy-box (precio/carrito/sugeridos) es sticky y suele terminar
siendo la columna más alta del grid de producto -- al ser un grid de
una sola fila, el carrusel de recomendaciones de abajo (sección
aparte) solo empezaba una vez terminaba la columna MÁS alta,
dejando un hueco vacío enorme debajo de galería+descripción cuando
esas dos son más cortas que el buy-box.
Se reestructura a un grid de 2 filas desde xl: el buy-box ahora ocupa
ambas filas (row-span-2, sigue sticky), y "También te puede gustar"
pasa a ser la 2da fila pero solo debajo de galería+descripción
(col-span-2) -- corre en paralelo al buy-box en vez de esperarlo.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- "Detalles del producto" (marca, edad, categoría, colección) pasa de
acordeón colapsado a una mini tabla siempre visible -- mismos datos
reales de siempre, solo que ya no hace falta un click para verlos.
- Buy-box: nueva franja de confianza real con enlaces a /envios y
/cambios (5 días hábiles, envío de vuelta gratis) -- mismas
políticas ya publicadas en el footer, no texto inventado.
- "También te puede interesar" ahora muestra hasta 2 sugerencias en
vez de 1, mismo cálculo real por categoría/colección que ya
alimentaba el carrusel de abajo.
Investigado de paso (sin fix): la discrepancia de stock reportada
("Quedan 1" vs available:false) no es un bug de ProductPurchase -- la
página de producto usa /store/products de Medusa (en vivo, correcto),
mientras que /store/search (Meilisearch) devuelve inventario
desactualizado para el mismo producto. Eso vive en commerce-backend,
fuera de alcance de este cambio de frontend.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- max-w de la página de producto pasa de 1280px a 1600px -- aprovecha
pantallas grandes en vez de dejar margenes vacíos enormes.
- Nueva 3ra columna desde xl: buy-box propio (tarjeta con borde,
sticky) para precio/variantes/carrito, separado del texto
descriptivo. Por debajo de xl cae al flujo normal de la 2da columna
sin duplicar el componente (mismo <ProductPurchase>, un solo
id="product-buy-button" en el DOM).
- Dentro del buy-box: un producto sugerido real de la misma
categoría/colección (mismo cálculo que ya alimenta el carrusel de
abajo, no un dato inventado).
- MobileAddToCart: la barra fija ahora también requiere haber
scrolleado más de 80px además de que el botón real no esté en
pantalla -- antes aparecía inmediatamente sobre la vista inicial del
producto (imagen + variantes) apenas cargaba la página.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- El panel de zoom lateral ahora calcula tamaño/posición contra el
viewport real (window.innerWidth/innerHeight) en cada mousemove, en
vez de un breakpoint fijo (lg:block) con ancho fijo -- a resoluciones
altas con poco espacio a la derecha de la imagen terminaba
saliéndose de la pantalla. Si no entra un panel de al menos
PANEL_MIN_SIZE, se cae automáticamente al overlay in-place (antes
atado a "por debajo de lg", ahora a "no hay espacio real").
- La barra fija de "Añadir al carrito" en mobile quedaba fija desde el
primer render, tapando permanentemente parte de la imagen del
producto. Ahora el botón real (ProductPurchase) también se muestra
en mobile, y la barra sticky solo aparece cuando ese botón sale del
viewport (IntersectionObserver) -- mismo patrón que el "Buy Now"
sticky de Amazon.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
Sin cambio de comportamiento -- solo hooks estables para la suite de
Playwright en .browser-scratch/checks.mjs (el selector genérico
div[aria-hidden] dejó de alcanzar para distinguir la lupa, el overlay
in-place y el panel lateral, que ahora coexisten en el DOM).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
El zoom in-place anterior (overlay de fondo dentro de la misma caja)
seguía sintiéndose confuso: el usuario esperaba el patrón clásico de
e-commerce (Amazon) -- un recuadro semitransparente ("lupa") que marca
qué región de la foto se está mirando, y un panel aparte, más grande,
que muestra esa región ampliada. Sin scroll ni superposición dentro de
la misma caja.
- Lupa: recuadro que sigue al cursor sobre la imagen principal,
clampeado para no salirse de los bordes (visible en lg+).
- bgX/bgY ahora se calculan por fracción de recorrido de la lupa
(posición del borde de la lupa entre 0 y el ancho disponible), no por
posición cruda del cursor -- mapeo correcto incluso cuando la lupa
queda pegada a un borde.
- Panel: a la derecha de la imagen (position absolute, se superpone al
contenido de al lado igual que en Amazon), mismo mecanismo de
background-image con la foto completa que ya evitaba el problema de
object-cover recortando partes de la imagen.
- Por debajo de lg (sin espacio para un panel aparte) se conserva el
overlay in-place como fallback.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
El hover-zoom escalaba (transform:scale) la misma <Image> ya recortada
por object-cover -- para fotos verticales (ej. figura de acción de pie)
eso dejaba partes del producto (la cabeza) fuera del recorte desde el
propio render, así que ningún nivel de zoom las mostraba. Se reemplaza
por un overlay con background-image usando la foto completa
(background-size en un solo valor preserva el aspect ratio real) y
background-position siguiendo el cursor, que sí permite recorrer toda
la imagen.
De paso: las miniaturas ahora cambian la imagen principal también con
hover (onMouseEnter), no solo con click.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
- ENVIOCLICK_ORIGIN_DANE_CODE en configmap.yaml estaba cargado con el
código DANE de Bogotá (11001000) por error; corregido a Santiago de
Cali, Valle del Cauca (76001000). Toda cotización real de envío hecha
antes de esta fecha se calculó desde el origen equivocado.
- Corregido también el fallback stale en medusa-config.ts
(originDaneCode) que seguía apuntando a Bogotá.
- Actualizado el texto de /envios y /contacto (misma dirección física,
Cra 100 #42-65, solo cambia la ciudad).
- Footer: Instagram y Facebook reales enlazados (verificados en vivo
contra los perfiles antes de linkear), y "Preguntas frecuentes" /
"Cambios" ya apuntan a contenido real en vez de /catalog.
- Nuevas páginas /cambios (5 días hábiles, envío de devolución a cargo
de ARI Shopping) y /preguntas-frecuentes.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
El footer "Ayuda" no tenía contenido real -- los 4 links apuntaban a
/catalog. Se agregan dos páginas nuevas con información real:
- /envios: transportadoras reales de EnvíoClick (Coordinadora, Envía,
TCC, Rappi, Pibox -- confirmadas en una cotización real, ver ADR
0010 §2) y cómo se calcula el costo.
- /contacto: WhatsApp real (+57 301 613 8155, mismo número ya usado en
el fallback de checkout) y correo [email protected] (reenviado
por Cloudflare Email Routing -- configuración manual de DNS pendiente
del lado del usuario, fuera de alcance de este cambio).
"Preguntas frecuentes" y "Cambios" quedan sin contenido real definido
todavía, apuntando a /catalog como antes.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Checkout con carrito vacío, fallback WhatsApp, filtros de catálogo,
búsqueda sin resultados (bug encontrado y corregido en otro commit),
slug de producto inválido (hallazgo pendiente de investigar: responde
200 en vez de 404), zoom interactivo, wishlist, otros viewports,
página 404 genérica, accesibilidad básica del buscador, y registro/
login con email -- todo verificado con Chromium real vía el harness de
.browser-scratch/.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Encontrado probando en vivo con navegador real (Docker + Playwright):
una búsqueda de 2+ caracteres sin coincidencias no mostraba nada -- ni
"Buscando…" ni ningún mensaje. El usuario quedaba sin feedback de que
su búsqueda de verdad corrió. Se agrega un estado vacío con el botón
de buscar en todo el catálogo (esa búsqueda completa puede usar
criterios más amplios que el autocompletado).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Cubre el resto de la superficie del storefront (checkout edge cases,
filtros de catalogo, variantes de producto, cuenta/wishlist,
accesibilidad, otros viewports) agrupado por riesgo, no solo lo ya
probado.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Cierra la mayor parte del checklist de pruebas reales que había quedado
pendiente: pago PSE hasta la redirección al banco, envío calculado
visible, banner y buscador mobile -- todo verificado con Chromium real
(Docker + Playwright en la NAS, ver .browser-scratch/README.md). Deja
documentados los 2 bugs reales que esa prueba encontró (ya corregidos
en otro commit) y un checklist ampliado para la próxima sesión.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Encontrado probando en vivo con navegador real (Docker + Playwright
contra shop.cruzcloud.net):
- app/page.tsx: un producto featured pero sin stock (available: false)
salía primero en el hero banner con botón "Comprar ahora" hacia una
página donde en realidad no se puede comprar.
- pse-banks/route.ts: el sandbox real de ePayco (bank.getBanks())
incluye un primer elemento placeholder (bankCode "0", "A
continuación seleccione su banco...") mezclado con los bancos reales,
mostrado como opción seleccionable. Se filtra explícitamente.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Trivy bloqueó el build del frontend en el pipeline de CI: Next.js
15.5.10 tiene un RCE no autenticado en la API de optimización de
imágenes vía archivos AVIF (CVE-2026-75604 / GHSA-2xp9-vwfh-vxw4),
corregido en 15.5.24. Bump mínimo dentro del mismo minor, sin otros
cambios de dependencias.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
- hero-carousel.tsx: slides derivados de productos featured/nuevos reales
(imagen, nombre, precio, link, badge de % OFF) en vez de un array
hardcodeado; fallback estático solo si la tienda no tiene productos
activos.
- navbar.tsx: en mobile/tablet el buscador pasa a una segunda fila a
todo el ancho debajo de logo+iconos (antes competía por espacio en una
sola fila y quedaba recortado); desktop sin cambios.
- docs: ADR 0010 documentando el pivote de pago PayU→Rapyd(descartado)→
ePayco, la integración de EnvíoClick y checklist de pruebas reales
pendientes desde el storefront; puntero agregado desde ADR 0009.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Placeholders reemplazados por los datos reales que dio el usuario --
dirección de la bodega (Cra 100 #42-65), email y teléfono de contacto.
Estos valores aparecen como remitente en las guías reales de EnvíoClick.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.
Investigación en vivo (no solo documentación -- misma disciplina que
ya costó varios rehechos con las pasarelas de pago): la SPA de docs
públicas (apidoc.envioclickpro.com.co) no renderiza sin sesión, pero
está generada con apiDoc, así que se bajó y leyó directo su
api_data.json/api_project.json (spec completa real), y se probó
/quotation en vivo con la API key real del usuario -- 200 OK con
tarifas reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. Esto corrigió
varios datos mal asumidos de un plugin de WordPress desactualizado
usado como referencia inicial (API v1): la URL base real es
api.envioclickpro.com.co/api/v2 (no api.envioclickpro.com a secas), y
el header de auth real es "Authorization" a secas, no
"AuthorizationKey".
Hallazgo crítico evitado leyendo el código fuente de
@medusajs/utils/dist/common/define-config.js: defineConfig() ya
registra por defecto un módulo de fulfillment con el provider
manual_manual, aunque medusa-config.ts no lo mencione explícitamente
-- pero transformModules() indexa por serviceName y el último que
llega pisa al anterior (no mezcla providers). Agregar un bloque propio
de fulfillment sin incluir también manual_manual ahí habría roto en
silencio el envío manual que ya usa la shipping option existente.
Verificado cargando la config compilada real: ambos providers quedan
registrados juntos.
- src/modules/envioclick-fulfillment/: AbstractFulfillmentProviderService.
calculatePrice() cotiza en vivo durante el checkout -- Medusa advierte
explícitamente que un error acá bloquea el checkout completo, así que
NUNCA se deja propagar una excepción: cualquier falla (red, dirección
sin id DIVIPOLA, EnvíoClick caído) cae al precio fijo de $15.000 que
ya se usaba, para no romper la compra. createFulfillment() vuelve a
cotizar en el momento de generar la guía real en vez de reusar el
idRate mostrado en checkout (más correcto: CalculatedShippingOptionPrice
no tiene forma de persistir ese id entre llamadas, confirmado leyendo
el tipo real, y un idRate viejo podría estar vencido si el fulfillment
pasa horas/días después). cancelFulfillment() sí cancela de verdad
(cancellation/batch/order existe y está confirmado, a diferencia del
refund de ePayco que quedó sin implementar).
- Solo 1 paquete por cotización/guía (límite real de la API, confirmado:
"min 1, max 1") -- el carrito se agrega a un solo paquete. Como hoy
NINGÚN producto tiene peso/dimensiones cargados (confirmado en vivo:
18 productos activos, 0 con weight), se usa un paquete de fallback
conservador completo en vez de mezclar datos reales con inventados a
medias -- la cotización no va a ser precisa hasta que se carguen esos
datos (tarea de datos, no de código, fuera de alcance de este commit).
- Frontend: cambio chico pero necesario en checkout-client.tsx/
medusa-cart.ts -- el checkout mandaba el nombre de la ciudad a Medusa,
nunca el id DIVIPOLA (aunque el form sí lo tenía disponible). Hay
municipios con el mismo nombre en distintos departamentos de Colombia,
así que el nombre solo no alcanza para identificar la ciudad sin
ambigüedad -- se guarda el id real en la metadata de la dirección
(Medusa sí tiene ese campo), que el fulfillment provider lee para
derivar el código DANE real que pide EnvíoClick (5 dígitos + "000" de
relleno, confirmado contra su propio ejemplo: "11001000" = Bogotá).
Pendiente de verificar con una llamada real (no se quiso generar una
guía de prueba en la cuenta real del usuario sin avisar): /shipment_sandbox
y cancellation/batch/order, verificados contra la spec oficial real
pero no probados en vivo todavía.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
El commit anterior agregó epayco-sdk-node a package.json pero el
lockfile nunca se actualizó (se generó localmente con
--package-lock=false para evitar un problema no relacionado del
registry en este entorno) -- npm ci en el Dockerfile exige que el
lockfile esté sincronizado con package.json y rompió el build en CI.
Regenerado con npm install normal; confirmado que npm ci (el comando
real del Dockerfile) ahora sí instala limpio.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.
- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
contrato AbstractPaymentProvider (initiatePayment/authorizePayment
"pending_authorization"/getWebhookActionAndData con firma SHA256
verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
llamar la API cruda -- leyendo el código fuente del SDK (no la
documentación pública, que resultó desactualizada en más de un campo)
se confirmó que el protocolo real de ePayco para PSE es bastante más
particular de lo que sugieren los ejemplos: cada campo del payload se
cifra con AES usando la private key del comercio, más un login previo
que da un bearer token que también viaja en la misma request.
Reimplementar esto a mano tenía mucho más riesgo de error que delegar
en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
`bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
búsqueda, pero no existe en el SDK real -- confirmado leyendo
node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
campo real para la URL de confirmación es `method_confirmation`
(con guión bajo en medio), no `metodoconfirmacion` como mostraba la
documentación pública -- confirmado contra tests/bank.js del propio
paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
ePayco documentado); refundPayment sigue sin implementar -- ese
endpoint no quedó verificado en vivo, tira error explícito en vez de
fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
persona + documento (a diferencia del diseño que se había planeado
para Rapyd, donde esos datos se recolectaban en la página del propio
Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
el código con qué hacer si fallan): el nombre exacto del campo con la
URL del banco en la respuesta de bank.create() (initiatePayment tira
un error con el payload completo si no matchea ningún candidato
probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
API keys o valores aparte del dashboard de ePayco.
Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
ADR 0009 ya había decidido PayU LatAm como proveedor (única opción que
no exige cuenta Bancolombia/Nequi) y confirmado que no existe ningún
plugin real de Medusa para su API -- los paquetes que aparecen
buscando "PayU" son para PayU Polonia/CEE, API completamente distinta
(clientId/posId/secondKey vs. apiLogin/apiKey/merchantId/accountId).
Este commit implementa esa decisión: módulo de pago custom
(AbstractPaymentProvider) para PSE (transferencia bancaria), primer
alcance del método de pago -- Nequi/Daviplata quedan fuera por ahora.
Backend (commerce-backend):
- src/modules/payu-pse/: initiatePayment arma y postea SUBMIT_TRANSACTION
contra la API real de PayU LatAm; authorizePayment devuelve
"pending_authorization" (PSE es async, la confirmación real llega por
webhook, no en el momento de completar el carrito); getWebhookActionAndData
valida la firma MD5 del webhook antes de confiar en cualquier
confirmación de pago -- falla cerrado si no matchea. refundPayment/
retrievePayment tiran error explícito en vez de simular éxito: esos
endpoints de PayU no quedaron verificados en vivo y hay dinero real de
por medio.
- No hace falta un subscriber propio para completar la orden -- el
subscriber nativo de Medusa (payment-webhook + process-payment-workflow)
ya hace ese trabajo con los métodos de arriba bien implementados
(confirmado leyendo el código fuente instalado de @medusajs/medusa
2.18.0, no la documentación).
- Correlación webhook↔sesión: Medusa inyecta su propio session_id en
`data` antes de initiatePayment; se usa ese mismo valor como
referenceCode de PayU, así el reference_sale que vuelve en el webhook
ya es directamente el session_id de Medusa.
- src/lib/payu.ts: cliente HTTP + firmas (orden y webhook, fórmulas
distintas, confirmadas contra el SDK PHP oficial de PayU).
- src/api/store/payments/pse-banks/route.ts: lista de bancos PSE,
cacheada en memoria.
- medusa-config.ts, Dockerfile, configmap/secrets.template: 7 env vars
nuevas (PAYU_API_LOGIN/API_KEY/MERCHANT_ID/ACCOUNT_ID/ENV/RESPONSE_URL/
NOTIFY_URL), dummies de build agregados, secrets con placeholder
"CAMBIAR".
Frontend (ecommerce):
- Nuevo step "payment" en components/checkout/checkout-client.tsx
(reemplaza al step "review" que solo ofrecía WhatsApp): selector de
banco, tipo de persona (N/J), tipo/número de documento, botón "Pagar
con PSE" que crea la sesión de pago, completa el carrito y redirige
al banco (navegación completa, no iframe -- así lo exige PayU).
WhatsApp queda como respaldo manual si falla la iniciación del pago.
- lib/headless/providers/medusa-payment.ts + 3 rutas proxy nuevas bajo
app/api/cart/, mismo patrón que el resto del carrito
(medusaCartFetch, ahora exportado desde medusa-cart.ts).
- app/checkout/pse/return/: página de vuelta del banco -- nunca afirma
éxito, la confirmación real es el webhook async.
Arranca contra las credenciales de sandbox genéricas y públicas de
PayU (developers.payulatam.com) mientras el usuario termina su propio
registro de comercio -- swap de las 4 credenciales via secret cuando
estén listas, sin cambios de código.
Verificado: build real de Medusa con las env vars dummy (no solo tsc),
tipos de AbstractPaymentProvider/WebhookActionResult/PaymentActions
confirmados contra @medusajs/utils y @medusajs/types instalados
(node_modules limpiado después), typecheck + lint + build completo del
frontend, páginas nuevas renderizando sin error. Pendiente: prueba
end-to-end contra el sandbox real de PayU (necesita el backend
desplegado) y habilitar el provider en la región Colombia desde el
Admin.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
El panel del mega-menu tenía mt-1 respecto al botón -- como es
position:absolute no suma a la altura del contenedor que escucha
mouseenter/mouseleave, así que ese margen quedaba como una franja
muerta: mover el cursor del botón hacia el panel disparaba
mouseleave a mitad de camino y cerraba el menú antes de poder hacer
clic en una categoría (reportado en vivo).
Se elimina el gap (mt-1) y se agrega un delay de 200ms antes de cerrar,
cancelable si el cursor vuelve a entrar, como margen extra para
movimientos rápidos o en diagonal cerca del borde.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
El <aside> de Filtros es sticky pero sin altura máxima ni overflow
propio -- con 30+ categorías más marca/edad/precio, el panel se pasa
del viewport y queda inalcanzable (reportado en vivo: había que bajar
el zoom del navegador para ver "Marca"/"Edad"/"Precio").
overflow-y-auto + max-h acotado al viewport (solo en lg, que es donde
aplica sticky) le da scroll propio al panel sin depender del scroll de
la página.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Re-subir la misma fila de CSV (ej. para agregar más fotos o actualizar
la descripción de un producto ya cargado) creaba un producto nuevo con
handle distinto -- confirmado en vivo re-importando "Hello Kitty &
Friends" para sumar fotos: quedaron dos productos con el mismo título
en la tienda.
Ahora, si el título coincide (case-insensitive/trim) con un producto ya
importado, se actualiza ese producto en vez de crear uno: las fotos
nuevas se agregan a las que ya tenía (no se reemplazan) y la
descripción se actualiza si la fila trae una. No toca
precio/cantidad/categoría/colección del producto existente.
El duplicado ya creado se fusionó en vivo (fotos movidas al original,
duplicado borrado) sin esperar este deploy.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Antes el único zoom era el modal interactivo (click -> Dialog con scale/
drag). Ahora la imagen principal también hace zoom estilo lupa (2.2x)
que sigue la posición del cursor con mousemove/transform-origin, sin
tocar el modal existente (sigue abriéndose igual con el click).
Solo mouse -- mousemove no dispara de forma continua en touch, así que
en mobile simplemente no aparece el efecto, sin romper nada ahí.
Verificado: tsc --noEmit limpio, página de producto renderiza 200 con
datos mock y el nuevo markup presente. No se pudo verificar visualmente
el hover en un navegador real (sin chromium-cli ni claude-in-chrome
disponibles en este shell headless).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
El importador solo enlazaba categoría/colección cuando el nombre del CSV
matcheaba exacto (case-insensitive) una ya existente -- si no había match,
category_ids/collection_id quedaban undefined sin ningún error. Así quedó
el producto "Hello Kitty & Friends" importado sin colección: la colección
nunca existió en la tienda, el importador nunca la creó ni avisó.
Ahora, si el nombre no matchea, se crea la categoría/colección nueva
(createProductCategoriesWorkflow / createCollectionsWorkflow) con el mismo
slugify() ASCII-safe que ya usa el handle del producto, por la misma razón:
el default de Medusa no quita tildes/eñes y rompe la ruta pública.
El producto ya importado se corrigió en caliente vía Admin API (colección
creada + enlazada + handle corregido de "hello-kitty-&-friends" a
"hello-kitty-friends"), sin esperar este deploy.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
Actualiza sesion-tmux-persistente.md: la imagen de tmux-host ahora se
construye desde scripts/tmux-host/Dockerfile (WORKDIR fijo en la raiz
del lab) en vez del docker run inline anterior, y se agrega la seccion
del chequeo de salud en background (red + sesion tmux, cada 30 min,
notificacion via el mismo canal de Telegram del lab-agent). Actualiza
tambien la tabla "Que sobrevive a que" para reflejar la auto-recuperacion
de la sesion tmux.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012xyPak6HWFmBCqXrvFsejL
3 productos importados con título acentuado (ñ/tildes) quedaron con handle
no-ASCII y daban 404 real al entrar por shop.cruzcloud.net -- el tunel/
ingress público rompe rutas con UTF-8 no-ASCII en el path, confirmado en
vivo comparando el mismo request directo al servicio (200 OK) contra el
dominio público (404). Se genera el handle explícito (slugify propio,
sin dependencia nueva) con sufijo único por fila para evitar colisiones
entre títulos parecidos. Los 3 productos ya publicados con handle roto
se corrigieron en caliente vía Admin API, sin esperar este deploy.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
La spec original (Navy #122844/Coral #D85A30) fue reemplazada en producción
el mismo día por una paleta azul/amarillo estilo Walmart
(fix/adr0008-walmart-palette-blue-yellow), pero nunca quedó documentada
como enmienda. Se agrega §8 con los valores reales vigentes en globals.css
y §9 con auditoría por sub-fase contra el código en main: sub-fases 1-4
completas, sub-fase 5 parcial (product-purchase migrado, cart-drawer y
wishlist-drawer sin tocar, alias de compatibilidad de button.tsx sin
eliminar).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
El S3 file provider de Medusa descarta cualquier subcarpeta del
filename (usa solo path.parse().name + ulid, confirmado leyendo
@medusajs/file-s3/dist/services/s3-file.js) -- no existe una carpeta
import-products/ separada como decia el comentario original. El bucket
(ari-products) es plano, igual para todas las fotos del catalogo.
Confirmado en vivo con una importacion real de 3 productos.
listProductCategories({})/listProductCollections({}) sin select
explicito devolvian name/title undefined (a diferencia de la capa HTTP
del Admin API, que si los trae completos por defecto) -- causaba un 500
"Cannot read properties of undefined (reading 'toLowerCase')" en
cualquier import real. Confirmado en vivo con una prueba de importacion
de 3 productos antes de este fix.
De paso, se agrega un filtro defensivo antes del .map() para que una
categoria/coleccion sin nombre no tumbe el import entero.
Nueva pagina "Importacion" en Medusa Admin para cargar varios productos
de una sola vez (comprados en Amazon/eBay/etc, no dropshipping
automatizado -- se evaluo y descarto raspar catalogos de terceros sin
autorizacion por riesgo de copyright/ToS).
src/api/admin/product-import/route.ts: GET sirve la plantilla CSV
descargable, POST procesa fila por fila (una foto rota o categoria mal
escrita no tumba el resto del archivo). Cada foto se descarga del origen
y se re-aloja en el mismo MinIO/S3 del NAS que ya usa el catalogo
(prefijo import-products/, separado de ari-products/), se crea el
registro de inventario con la cantidad indicada y el producto queda
vinculado con el mismo patron "Presentacion"/"Estandar" que usa el resto
del catalogo -- el storefront no necesita cambios.
src/lib/csv.ts: parser CSV propio (RFC4180), sin sumar dependencia nueva.
Workflows y servicios de modulo (uploadFilesWorkflow,
createInventoryItemsWorkflow, createProductsWorkflow, stock-location/
fulfillment/sales-channel/product module services) verificados contra
los .d.ts reales instalados, no solo contra documentacion.
variant.available (calculado en lib/headless/providers/medusa.ts a
partir de manage_inventory/allow_backorder/inventory_quantity) ya
llegaba correcto hasta el componente, pero ningun boton de compra lo
usaba -- se podia seguir agregando al carrito un producto con stock 0.
Afecta product-purchase.tsx (pagina de producto), mobile-add-to-cart.tsx
(barra fija mobile) y product-card.tsx (tarjetas de catalogo): boton
deshabilitado + label "Agotado" en los tres, mas badge/overlay en la
tarjeta de catalogo.
TS2769: la anotacion explicita {location_id: string} no calzaba con el
tipo Maybe<InventoryLevel> que infiere query.graph para location_levels.
Se quita la anotacion y se deja inferir, con optional chaining.
Nueva pestana "Inventario" en el sidebar del Admin (src/admin/routes/
inventario), pensada para un usuario no tecnico: una fila por producto
(catalogo real confirmado 1 variante por producto), switch para activar
seguimiento de stock, input de cantidad que guarda solo, badge de stock
bajo (<=5 unidades) y activacion/desactivacion en bloque por seleccion.
No reemplaza la vista nativa de Inventory de Medusa, es un atajo.
Backend: src/api/admin/inventory-panel/route.ts + workflow propio
src/workflows/attach-inventory-item-to-variant.ts para vincular un
InventoryItem nuevo a una variante existente (Medusa no lo hace solo al
togglear manage_inventory en un producto ya creado -- confirmado en vivo
contra un producto [DEMO] antes de escribir el codigo).
Nombres de campos y workflows verificados contra los .d.ts reales
instalados en el pod (no solo contra la documentacion, que no cubre bien
este flujo de vincular inventario a una variante existente).
Implementa el flujo completo de OAuth social de Medusa para el
storefront: boton "Continuar con Google" en account-dialog, ruta proxy
que arranca el flujo (GET /api/customer/google/start) y pagina de
callback en /auth/google/callback (coincide con GOOGLE_CALLBACK_URL del
secret) que resuelve el code/state contra el backend, decodifica el JWT
para distinguir cliente nuevo vs existente, crea el customer si hace
falta y refresca el token a sesion final -- mismo patron de
store/customer-store.ts que login/register por email.
Flujo verificado en vivo contra el backend ya desplegado (GET
/auth/customer/google, /auth/customer/google/callback con state
invalido, /auth/token/refresh con bearer invalido) antes de escribir el
codigo, no solo contra la documentacion.
npm run build (medusa build) carga medusa-config.ts, que ahora exige
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALLBACK_URL via required().
Sin valores dummy en el builder stage, el build fallaba con "Error in
loading config: La variable GOOGLE_CLIENT_ID es obligatoria" -- mismo
patron ya usado para JWT_SECRET/MEILISEARCH_API_KEY.
Agrega el modulo @medusajs/medusa/auth con providers emailpass (explicito,
para no perder el default al customizar el modulo) y google, leyendo
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALLBACK_URL desde
commerce-secrets con required().
Pedido explicito del usuario tras probar el login real: el boton de
'Mi cuenta' estaba oculto en mobile (hidden sm:inline-flex, solo
alcanzable desde el menu hamburguesa). Pasa a estar siempre visible en
el header, y agrega un punto verde cuando hay sesion iniciada -- mismo
patron visual que los badges de cantidad de carrito/favoritos, pero de
presencia (con sesion o sin ella), no de conteo.
Conecta el dialogo 'Tu cuenta ARI' (antes solo ofrecia WhatsApp) al
Store API real de Medusa. Flujo verificado en vivo con un cliente de
prueba real antes de escribir el codigo: registro -> crear customer
-> login -> /store/customers/me, todo confirmado funcionando.
Detalle no documentado que se confirmo en vivo: el token que devuelve
/auth/customer/emailpass/register sirve para crear el customer via
POST /store/customers, pero no queda como sesion real -- hay que
loguear de nuevo (/auth/customer/emailpass) para obtener el token de
sesion. Mensajes de error tambien verificados: correo repetido -> 401
'Identity with email already exists'; credenciales invalidas -> 401
'Invalid email or password'.
Mismo patron arquitectonico que el carrito real (ADR 0009): rutas
puente /api/customer/* (MEDUSA_BACKEND_URL es DNS interno, no resuelve
desde el navegador), store de Zustand con sesion persistida y
resincronizada contra el servidor al rehidratar. WhatsApp se mantiene
como opcion secundaria, ya no es la unica salida.
Reemplaza los inputs de texto libre de Ciudad/Departamento por dos
selects en cascada, poblados con el dataset oficial de DIVIPOLA (33
departamentos + 1123 municipios, fuente proyecto26/colombia,
verificado contra la codificacion real del DANE antes de incorporarlo
-- id 5001 = Medellin, departmentId 5 = Antioquia, etc.). Sin
dependencia npm nueva (JSON estatico + helper propio).
Se guarda el id DIVIPOLA seleccionado en el estado del formulario, no
solo el nombre -- EnvioClick (la integracion de envios real que
reemplazara la tarifa fija de este ADR) va a necesitar identificar la
ciudad sin ambiguedad, y las transportadoras colombianas referencian
ciudades por este codigo. El nombre derivado del id sigue siendo lo
que se manda a Medusa (shipping_address espera texto, no cambia el
contrato existente).
Siguiente corte tras el carrito real: pagina /checkout de 3 pasos
(direccion -> envio -> confirmar) contra el Store API real, usando la
shipping option que ya se verifico en produccion. Endpoints nuevos
(setShippingAddress, getShippingOptions, addShippingMethod) probados
en vivo con un carrito de prueba real antes de escribir el codigo --
direccion, opciones de envio y total ya confirmados correctos
(80.000 + 5.000 = 95.000).
El pago (PayU custom) sigue sin existir, asi que el ultimo paso sigue
siendo WhatsApp -- pero ahora el mensaje incluye direccion, metodo de
envio y total real en vez de solo la lista de productos. El boton del
CartDrawer pasa de abrir WhatsApp directo a llevar a /checkout.
Reportado en produccion: el carrito mostraba el logo generico de ARI
en vez de la foto del producto. Verificado en vivo: item.thumbnail
viene null en la respuesta de Medusa para todo el catalogo real
(confirmado tambien contra /store/products directamente -- el campo
thumbnail del producto es null en la fuente, no solo omitido en la
respuesta del carrito). El catalogo ya resuelve esto cayendo a
images[0] (mapProduct() en medusa.ts); la linea del carrito necesita
pedir esa relacion explicita via fields=*items.product.images (no
viene por defecto) y aplicar el mismo fallback.
Primer corte de checkout real, a proposito acotado: el carrito pasa de
estado 100% local (localStorage con productos completos) a sincronizar
con un cart_id real de Medusa via /api/cart/* (rutas puente
same-origin, mismo patron que /api/search -- MEDUSA_BACKEND_URL es DNS
interno del cluster, no resuelve desde el navegador).
Formas de respuesta de la API verificadas en vivo con un carrito de
prueba real antes de escribir el mapeo (no asumidas contra
documentacion): agregar el mismo variant_id dos veces hace merge de
cantidad automatico (no crea linea duplicada); eliminar una linea
devuelve el carrito actualizado en 'parent', no en 'cart' como los
demas endpoints.
La firma publica de useCartStore (addItem/removeItem/updateQuantity/
items) no cambia, asi que ningun componente consumidor (cart-drawer,
navbar, product-card, product-purchase, mobile-add-to-cart) necesito
tocarse. Sigue cerrando por WhatsApp -- direccion/envio/pago quedan
para los siguientes cortes del ADR 0009.
Verificado el codigo/README real de los dos unicos paquetes de Medusa
que aparecen buscando 'PayU' (@tax1driver/medusa-payu y
medusa-plugin-payu de rigby-sh, este ultimo archivado desde
2025-03-21): ambos usan la terminologia de la API de PayU Polonia/CEE
(clientId/posId/secondKey, OAuth), no la de PayU LatAm
(apiLogin/apiKey/accountId, developers.payulatam.com, la que cubre
Colombia). 'PayU' es una marca compartida por dos productos distintos.
La decision de negocio (PayU como proveedor) se mantiene -- cambia la
via tecnica: integracion via modulo de pago custom
(AbstractPaymentProvider de Medusa) contra la API real de PayU LatAm,
mismo tipo de esfuerzo que EnvioClick, no instalacion de plugin.
De paso, documenta las shipping options ya creadas y verificadas en
produccion (stock location, fulfillment set, service zone Colombia,
shipping option de 5.000 COP) incluyendo el paso no documentado que
las dejaba invisibles para el Store API (vinculo a sales channel).
Dato de negocio real del usuario: cuentas disponibles son Davivienda,
Lulo Bank y Banco de Bogota -- ninguna Bancolombia ni Nequi.
Verificado el mismo dia: Wompi exige cuenta Bancolombia o al menos
Nequi solo para REGISTRARSE como comercio (aunque la liquidacion
despues pueda ir a cualquier banco) -- ninguno de los bancos
disponibles califica sin abrir una cuenta nueva. PayU solo exige
cuenta bancaria colombiana a nombre del comercio, sin banco
especifico, y ya soporta PSE + Nequi + Daviplata para el cliente sin
importar el banco del comercio -- cubre exactamente lo que se pidio
(PSE + Nequi 'y demas bancos').
Estado pasa de propuesta a aceptada. Wompi se deja documentado como
deuda a reevaluar si en el futuro se abre cuenta Bancolombia/Nequi --
no se descarta por limitacion tecnica del plugin, solo por el
requisito de registro actual.
"deprecated":"Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
`ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0,500)}`,
"expression":"CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName":"IDX_product_review_product_id_status",
"columnNames":[],
"composite":false,
"constraint":false,
"primary":false,
"unique":false,
"expression":"CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
},
{
"keyName":"IDX_product_review_customer_id",
"columnNames":[],
"composite":false,
"constraint":false,
"primary":false,
"unique":false,
"expression":"CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
"expression":"CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
}
overrideasyncdown():Promise<void>{
this.addSql(`drop table if exists "product_review" cascade;`);
# Plugins y subagentes de Claude Code (sesión "lab")
Inventario de qué plugins/skills se instalaron en la sesión principal
`lab` (no `cloud-practice`, que sigue su propia línea de práctica
multi-cloud — ver [ADR 0012](../decisiones/0012-practica-multicloud-gcp-azure.md))
y qué subagentes propios se configuraron, con la fuente real de cada
uno y por qué se aceptó o se descartó.
## Por qué esto queda documentado como el resto del lab
Dos frentes reales motivaron esto: (a) trabajo visual del storefront
ARI Shopping — ADR 0008 ya costó tres ciclos completos de
implementar-y-descartar una identidad en producción por no verla
renderizada antes de aprobarla; (b) crecimiento de habilidades de
Platform Engineering/DevOps. Un plugin instala instrucciones que
Claude sigue automáticamente, algunos con hooks que se ejecutan sin
pedir confirmación en cada uso — se aplicó la misma disciplina de
verificación que el resto del lab: quién publica (Anthropic vs
comunitario), qué permisos/hooks declara, y popularidad como señal de
confianza, antes de instalar cualquiera que no fuera 100% de Anthropic.
## Plugins instalados
| Plugin | Marketplace | Publica de verdad | Hooks | Notas |
|---|---|---|---|---|
| `frontend-design` | `claude-plugins-official` | **Anthropic** (verificado en `plugin.json`) | Ninguno | Solo skills, sin red externa |
| `context7` | `claude-plugins-official` | **Upstash** (tag `community-managed` — está *hospedado* en el catálogo oficial, no *publicado* por Anthropic) | Sin hooks, pero es un MCP que manda cada consulta al servidor remoto de Upstash (`mcp.context7.com`) | Aceptado con esa salvedad explícita de datos saliendo a un tercero |
| `superpowers` | `claude-plugins-official` | **Jesse Vincent** (`obra/superpowers`, MIT, 288k+ ⭐ en GitHub) — **NO es de Anthropic** pese a estar listado en el catálogo oficial | Sí: `SessionStart` corre un script que inyecta contexto en cada sesión nueva (revisado: solo lee su propio skill local, sin red ni nada destructivo) | Aceptado con esa salvedad — cambia comportamiento de arranque sin pedir confirmación cada vez |
| `gitops-workflows` | `devops-skills` (`ahmedasmar/devops-claude-skills`) | Ahmad Asmar (comunitario) | Ninguno (skill pura, sin ejecución automática) | |
- **`iac-terraform`** (del mismo marketplace `devops-skills`) — descartado a propósito, no por falta de calidad. Terraform ya se maneja aparte vía `hashicorp/agent-skills` en la sesión `cloud-practice` (ver ADR 0012 Fase 2); instalarlo acá también duplicaría/confundiría la fuente de verdad.
- **`aws-cost-optimization`** (mismo marketplace) — no se pidió, se dejó afuera para no sumar alcance no solicitado.
- **`theme-factory`** — investigado y descartado: el mismo nombre aparece en al menos 4 fuentes comunitarias distintas y no relacionadas entre sí (`kivilaid`, `shipshitdev`, `composiohq`, etc.), ninguna en el catálogo oficial de Anthropic. Sin una fuente única verificable, no se instaló nada con ese nombre.
- **`ui-theme-designer`** (alternativa considerada del catálogo oficial) — es de **SAP SE**, específico para el SAP Fiori Design System / UI5 Web Components. No tiene nada que ver con el stack real de ARI Shopping (Next.js + Tailwind) — se descartó por no aplicar, no por falta de confianza.
## Subagentes propios (`~/.claude/agents/`)
Tres subagentes con propósito acotado y tools restringidas, definidos
como archivos markdown con frontmatter YAML (formato estándar de
Claude Code — ver [documentación oficial](https://code.claude.com/docs/en/sub-agents)).
### `devsecops-auditor`
Solo lectura y explicación del pipeline DevSecOps real (Gitleaks,
Trivy, Semgrep, Syft, Cosign) en `apps-registry`/`platform-infra`/