Compare commits

...
Author SHA1 Message Date
devops 271ea30f44 chore: promote v1.0.286 to production [skip ci] 2026-09-27 05:38:30 +00:00
devops d1fa58bf24 chore: release v1.0.286 [skip ci] 2026-09-27 05:38:29 +00:00
devops 8145e90441 Merge branch 'feat/navbar-desktop-categorias'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m1s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m58s
2026-09-27 05:22:21 +00:00
devopsandClaude Opus 5.5 e6f031a366 feat(storefront): barra de navegación desktop con categorías, personajes, ayuda y ubicación
- 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
2026-09-27 05:22:21 +00:00
devops 503f56977d chore: promote v1.0.285 to production [skip ci] 2026-09-27 04:20:53 +00:00
devops f8304633c1 chore: release v1.0.285 [skip ci] 2026-09-27 04:20:44 +00:00
devops a25cad6da3 Merge branch 'fix/product-card-galeria-object-contain'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 18m54s
2026-09-27 04:00:45 +00:00
devopsandClaude Opus 5.5 cbdb44c7dc fix(storefront): galería de tarjetas sin recortes y sin sobrecargar el optimizador
- 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
2026-09-27 04:00:44 +00:00
devops 09c756942b chore: promote v1.0.284 to production [skip ci] 2026-09-27 02:17:10 +00:00
devops a9ef0ed00a chore: release v1.0.284 [skip ci] 2026-09-27 02:17:08 +00:00
devops da7f316eb1 Merge branch 'feat/product-card-galeria-swipe'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 2m4s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m27s
2026-09-27 01:37:26 +00:00
devopsandClaude Opus 5.5 123cbbd87a feat(storefront): galería deslizable en las tarjetas de producto del listado
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
2026-09-27 01:37:25 +00:00
gitea-actions 496ad982f3 chore(gitops): deploy Docs Portal v1.0.283 [skip ci] 2026-09-27 00:31:40 +00:00
devops fa183a7115 Merge branch 'docs/tmux-host-ipv6-confirmado-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 13m45s
2026-09-27 00:16:38 +00:00
devopsandClaude Opus 5.5 4146245ae9 docs(playbooks): IPv6 confirmado tras segundo reinicio, cierre de pendientes
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
2026-09-27 00:16:37 +00:00
gitea-actions a38edfecc8 chore(gitops): deploy Docs Portal v1.0.282 [skip ci] 2026-09-26 19:33:01 +00:00
devops 2566e06abd Merge branch 'docs/tmux-host-ipv6-reinicio-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m35s
2026-09-26 19:22:40 +00:00
devopsandClaude Opus 5.5 c7ac1b52be docs(playbooks): resultado del reinicio real, IPv6 via /etc/sysctl.d en ZimaOS
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
2026-09-26 19:22:39 +00:00
gitea-actions 2e7a3f3d24 chore(gitops): deploy Docs Portal v1.0.281 [skip ci] 2026-09-26 14:51:18 +00:00
devops d9c2037c59 Merge branch 'docs/incidente-tmux-host-2026-09-25'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m15s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 14m51s
2026-09-26 12:39:31 +00:00
devopsandClaude Opus 5.5 7549dc86d2 docs(playbooks): incidente cadena de 4 fallos en tmux-host (2026-09-25/26)
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
2026-09-26 12:39:30 +00:00
gitea-actions 8e0d6abb82 chore(gitops): deploy Docs Portal v1.0.280 [skip ci] 2026-09-21 14:10:53 +00:00
devopsandClaude Sonnet 5 d98ec9a488 Merge branch 'docs/adr-0013-iniciativas-clientes-2026-09-21'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m48s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-21 14:01:30 +00:00
devopsandClaude Sonnet 5 03cfb203eb docs(adr-0013): iniciativas de clientes (Apps Script + AppSheet), fase prioritaria
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
2026-09-21 14:01:17 +00:00
gitea-actions 4553967bec chore(gitops): deploy Docs Portal v1.0.279 [skip ci] 2026-09-21 04:26:38 +00:00
devopsandClaude Sonnet 5 059597346d docs(adr-0012): documentar pivot de Fase 1 a Connectors nativos de claude.ai
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 36s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m20s
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
2026-09-21 04:16:19 +00:00
gitea-actions b55766847f chore(gitops): deploy Docs Portal v1.0.278 [skip ci] 2026-09-20 23:17:55 +00:00
devops 848bac0b2d Merge branch 'docs/plugins-y-subagentes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 57s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 11m58s
2026-09-20 23:04:41 +00:00
devops 8107f9b6c7 docs(arquitectura): documentar plugins y subagentes instalados en sesion lab 2026-09-20 23:04:20 +00:00
gitea-actions d82f4df83a chore(gitops): deploy Docs Portal v1.0.277 [skip ci] 2026-09-20 21:11:43 +00:00
devops 55eb9d8051 Merge branch 'docs/adr-0012-practica-multicloud-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 7m47s
2026-09-20 21:03:22 +00:00
devops fb89c26919 docs(adr-0012): practica multicloud GCP/Azure en paralelo al lab ZimaOS 2026-09-20 21:03:02 +00:00
gitea-actions c245f3b67a chore(gitops): deploy Docs Portal v1.0.276 [skip ci] 2026-09-20 20:42:06 +00:00
devops 149f743975 Merge branch 'docs/adr-0010-cierre-zoom-imagenes-manuales-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m16s
2026-09-20 20:32:18 +00:00
devops cc79d3b089 docs(adr-0010): cerrar bug de zoom con imagenes manuales, url() sin comillas 2026-09-20 20:31:58 +00:00
devops 5bcacdf09d chore: promote v1.0.275 to production [skip ci] 2026-09-20 20:22:40 +00:00
devops 3e5e18c14f chore: release v1.0.275 [skip ci] 2026-09-20 20:22:36 +00:00
devops e75a11d554 Merge branch 'fix/gallery-zoom-url-spaces-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m30s
2026-09-20 20:07:58 +00:00
devops 0fd05dab2a fix(ecommerce): comillas en url() del zoom, rompia con espacios/parentesis en el nombre de archivo 2026-09-20 20:07:32 +00:00
gitea-actions 5fe18ce3d4 chore(gitops): deploy Docs Portal v1.0.274 [skip ci] 2026-09-20 19:25:20 +00:00
devops 2318e1da7d Merge branch 'docs/adr-0010-cierre-bugs-mobile-imagenes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m18s
2026-09-20 19:18:22 +00:00
devops b6b2f15c12 docs(adr-0010): cerrar bug de overflow mobile 320px, actualizar estado de imagenes 2026-09-20 19:17:55 +00:00
devops 7658b9879a chore: promote v1.0.273 to production [skip ci] 2026-09-20 19:10:43 +00:00
devops a1dc145528 chore: release v1.0.273 [skip ci] 2026-09-20 19:10:41 +00:00
devops 4f5a715812 Merge branch 'fix/navbar-departamentos-overflow-320-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m57s
2026-09-20 18:58:13 +00:00
devops 5ce9c07829 fix(ecommerce): ocultar texto de Departamentos en mobile, 3 shrink-0 desbordaban 320px 2026-09-20 18:57:54 +00:00
devops 428ad83e5b chore: promote v1.0.272 to production [skip ci] 2026-09-20 18:27:46 +00:00
devops 80862bef5b chore: release v1.0.272 [skip ci] 2026-09-20 18:27:44 +00:00
devops 777e47ce16 Merge branch 'fix/footer-newsletter-overflow-320-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 27s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 17m33s
2026-09-20 18:09:35 +00:00
devops 167ae4057a fix(ecommerce): min-w-0 en Newsletter del footer, forzaba overflow a 320px 2026-09-20 18:09:10 +00:00
devops 51300c234a chore: promote v1.0.271 to production [skip ci] 2026-09-20 17:39:43 +00:00
devops 0c0efe3727 chore: release v1.0.271 [skip ci] 2026-09-20 17:39:42 +00:00
devops e4dafe9017 Merge branch 'ci/automate-staging-to-prod-promotion-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m47s
2026-09-20 17:30:00 +00:00
devopsandClaude Sonnet 5 fed5dfc1c1 ci(ecommerce): automatizar promocion de Staging a Produccion
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
2026-09-20 17:27:37 +00:00
devops 5773154db9 chore: release v1.0.270 [skip ci] 2026-09-20 17:04:00 +00:00
devopsandClaude Sonnet 5 553b1c7a32 Merge branch 'fix/medusa-fetch-cache-multi-pod-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m9s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:50:25 +00:00
devopsandClaude Sonnet 5 d9c39ba2a1 fix(ecommerce): sacar cache de fetch que competia contra force-dynamic
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
2026-09-20 16:50:16 +00:00
gitea-actions 164223e844 chore(gitops): deploy Docs Portal v1.0.269 [skip ci] 2026-09-20 16:24:54 +00:00
devopsandClaude Sonnet 5 ee327b420c Merge branch 'docs/adr-0010-bugs-imagenes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m35s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:17:55 +00:00
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
gitea-actions 6c6303cf8e chore(gitops): deploy Docs Portal v1.0.268 [skip ci] 2026-09-20 16:03:40 +00:00
devopsandClaude Sonnet 5 410975f972 Merge branch 'docs/tmux-host-remote-control-continue'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m10s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 15:57:01 +00:00
20 changed files with 1650 additions and 50 deletions

No files matched your search

+51 -2
View File
@@ -129,9 +129,16 @@ jobs:
# Cambio 2026-09-14: todo push a main promueve a STAGING
# automaticamente (workloads/ecommerce-staging), no a Produccion
# directo -- decision del usuario para dejar de probar cambios en
# vivo contra Prod. Promover a Produccion (workloads/ecommerce) es
# una accion manual aparte, ver scripts/promote-to-production.sh.
# vivo contra Prod.
# Cambio 2026-09-20: la promocion de Staging a Produccion (antes
# scripts/promote-to-production.sh, manual con confirmacion
# interactiva) ahora corre sola en este mismo job -- ver el step
# "Promover a Produccion (CD)" mas abajo, justo despues del de
# Staging. Decision explicita del usuario: cero gate humano entre
# Staging y Produccion. El script manual queda solo como
# herramienta de rollback/excepcion, ya no es el camino normal.
MANIFEST_FILE: workloads/ecommerce-staging/frontend/patches/deployment.yaml
PROD_MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce-staging/frontend
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
@@ -604,6 +611,48 @@ jobs:
# sobrescribir commits ajenos o promocionar una versión antigua.
git push origin HEAD:main
# Automatiza lo que antes era scripts/promote-to-production.sh (paso
# manual con confirmación interactiva, read -rp). Decisión del
# usuario 2026-09-20: cero pasos manuales entre Staging y
# Producción — la misma imagen que acaba de promocionar a Staging
# arriba se promueve sola a Producción, sin gate humano en el
# medio. Mismo sed/perl que el script manual (mismo MANIFEST, otro
# archivo), corriendo como un segundo commit separado justo
# después del de Staging — no se pisan en un solo commit porque
# Staging y Producción son releases conceptualmente distintos
# (permite revertir uno sin tocar el otro).
- name: Promover a Producción (CD)
if: steps.promotion.outputs.promote == 'true'
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}"
git config user.name "Cristian Felipe"
git config user.email "[email protected]"
# Re-fetch: el step de Staging de arriba ya pusheó un commit a
# main en este mismo job -- sin este refresh, este checkout
# local queda un commit atrás y el push de abajo se rechaza.
git fetch origin main
git checkout -B main origin/main
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${PROD_MANIFEST_FILE}"
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${VERSION}/" "${PROD_MANIFEST_FILE}"
git add "${PROD_MANIFEST_FILE}"
if git diff --cached --quiet; then
echo "Producción ya está en ${VERSION}."
exit 0
fi
git commit -m "chore: promote ${VERSION} to production [skip ci]"
git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
@@ -0,0 +1,111 @@
# 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) | |
| `k8s-troubleshooter` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `ci-cd` | `devops-skills` | Ahmad Asmar | Ninguno | |
| `monitoring-observability` | `devops-skills` | Ahmad Asmar | Ninguno | |
**Descartados, no instalados:**
- **`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`/
`scripts`. `tools: Read, Grep, Glob, Bash` + `disallowedTools: Write,
Edit` — nunca modifica código de producción, su único output es
diagnóstico contra el YAML/config real, citando archivo:línea, nunca
teoría genérica de CI/CD.
### `frontend-critic`
Revisa visualmente un cambio del storefront **antes** de aprobarlo —
formaliza la disciplina "simular antes de implementar" que costó tres
ciclos de descarte en ADR 0008. Regla dura: si no puede ver el
resultado renderizado en esa corrida puntual, el veredicto es "no
puedo aprobar esto sin verlo", nunca un aprobado condicional.
Vía de verificación, en orden de preferencia:
1. Chrome DevTools / Claude in Chrome — **hoy no disponible en esta
sesión** (corre headless dentro de `tmux-host`, sin navegador real
conectado — mismo gotcha ya documentado para Claude in Chrome en
general).
2. Playwright vía Docker en la NAS (el workaround ya validado este
mismo lab, ver `.browser-scratch/README.md`) contra
`shop.cruzcloud.net`/`staging.cruzcloud.net` reales.
Usa la skill `frontend-design` (oficial, ver tabla de arriba) como
marco de criterio de calidad visual.
### `cloud-cost-auditor`
Definido de antemano, a propósito, **sin nada que auditar todavía**.
Necesita los MCP de GCP/Azure que se configuran en la sesión
`cloud-practice` (ADR 0012) para funcionar — no va a tener recursos
reales que revisar hasta que esa línea de trabajo pase de la Fase 1.
Su propio system prompt tiene la regla dura de decir explícitamente
"no tengo nada que auditar todavía" en vez de inventar un análisis si
lo invocan antes de tiempo. No es un bloqueante para nada de lo demás,
solo queda listo para cuando aplique.
## Alcance y persistencia de la instalación
Los 7 plugins quedaron en **`scope: user`** (confirmado con
`claude plugin list`), no a nivel de proyecto de un repo específico.
Los 3 subagentes viven en `~/.claude/agents/` (también scope de
usuario, ubicación estándar según la doc oficial).
**Verificado en vivo que sobrevive a un reinicio del contenedor**,
mismo criterio que el resto de la configuración persistente de
`tmux-host` (ver [Sesión tmux persistente](sesion-tmux-persistente.md)):
`stat -c '%d' /root/.claude` y `stat -c '%d'
/DATA/AppData/k3d-lab-v3/claude-code-home` devuelven el mismo device
ID — es el mismo bind mount ya validado para login/confianza de
carpeta, no un directorio efímero del contenedor.
**Gotcha encontrado en vivo:** tanto los plugins como los subagentes
nuevos **no quedaron activos en la misma sesión donde se instalaron/
crearon** — la doc oficial de subagentes dice que se cargan sin
reinicio, *excepto* la primera vez que se crea el directorio
`~/.claude/agents/` (que no existía antes en este lab). Confirmado en
vivo: invocar `devsecops-auditor` en la misma sesión dio
`Agent type 'devsecops-auditor' not found`. Ambos (plugins y
subagentes) quedan pendientes de confirmar activos recién en el
próximo arranque real de la sesión — no es una falla de instalación,
es el comportamiento documentado.
@@ -408,6 +408,14 @@ después del swap.
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` | Sí, desde el 2026-09-20 — `--continue` retoma la conversación más reciente en `WORKDIR` (ver gotcha arriba) |
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) | Sí, mismo mecanismo — validado en vivo durante el swap de imagen del 2026-09-20 |
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A |
| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso |
!!! warning "Incidente relacionado"
La fila de expiración del token OAuth salió del
[incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md).
El síntoma "no aparecen las sesiones remotas" llevó a eliminar el
contenedor, aunque la causa real era un token vencido. **Antes de
tocar el contenedor, mirá el pane de cada sesión.**
## Validación en vivo
@@ -425,3 +425,66 @@ Resultados reales, uno por uno (código de cada prueba en
un dispositivo real.
21b. **Accesibilidad del checkout completo** por teclado (no solo el
buscador) — tab order y foco visible en los 3 pasos.
22. **Importación manual de imágenes no se reflejaba en el storefront —
RESUELTO 2026-09-20, con un giro real durante la verificación.**
Reportado por el usuario 2026-09-20. Causa raíz #1 (caché
multi-pod): `medusaFetch()` en `lib/headless/providers/medusa.ts`
usaba `next:{revalidate:60,tags:["medusa-products"]}`, caché en
memoria por-pod que sobrevivía a `force-dynamic`. Con
`frontend-deploy` en 2 réplicas sin cacheHandler compartido, el POST
de `/api/revalidate` del subscriber de Medusa solo llegaba a uno de
los dos pods — el otro seguía sirviendo datos viejos hasta que el
TTL expirara solo. Una edición puntual + revisar al toque tenía
~50% de chance de pegarle al pod stale; una importación CSV (mucho
más lenta) le daba tiempo de sobra al TTL para expirar en ambos.
**Fix #1:** se sacó el `next.revalidate`/`tags` del fetch.
Verificado en vivo por el usuario con una edición real (agregó
varias fotos manuales al producto `minnie-y-mickey-mouse`): las
fotos sí se reflejaron al toque, confirmando el fix #1 — pero
apareció un segundo síntoma real, no relacionado: "el zoom
interactivo no se visualiza" en las fotos subidas a mano, aunque sí
en las de CSV. **Causa raíz #2** (`components/product/product-gallery.tsx`):
el panel/overlay de zoom arma `backgroundImage: \`url(${selected})\``
sin comillas — un nombre de archivo con espacio y paréntesis (ej.
`descarga (4)-xxx.png`, el patrón por defecto de Chrome al descargar
una imagen sin renombrarla, exactamente como quedan las subidas
manuales, a diferencia de las de CSV que llegan con nombres
saneados) rompe la sintaxis de `url()` sin comillas — el navegador
descarta el valor completo en silencio (`backgroundImage: none`).
La lupa (recuadro) sí aparecía porque no depende de la URL; solo el
panel/overlay quedaba en blanco. **Fix #2:** comillas alrededor de
la URL en las dos ocurrencias del patrón. Verificado en vivo contra
las 9 fotos reales del producto (2 de CSV + 7 manuales con
espacio+paréntesis) con Playwright: `computed backgroundImage`
pasó de `none` a `url("...")` correcto en las 7 que fallaban, sin
tocar las 2 que ya andaban bien.
23. **En mobile (viewport real de 320px), la página se cortaba y
obligaba a scrollear horizontalmente — RESUELTO 2026-09-20.**
Reportado por el usuario con captura real de `shop.cruzcloud.net`.
**No era la galería de imágenes** (esa clase de bug con `min-w-0`
ya estaba corregida desde la sesión anterior, confirmado en vivo
que seguía sana). Dos causas reales encontradas por separado,
reproducidas con Playwright contra un viewport de 320×480 real:
- **Footer, bloque "Newsletter"** (`components/layout/footer.tsx`):
el párrafo de texto, sin `min-w-0`, le pedía al grid item su
ancho de una sola línea sin wrappear (~335px, medido en vivo)
en vez de dejarse achicar — forzaba la única columna del grid
(`sm:grid-cols-2 lg:grid-cols-4`, sin `grid-cols-1` propio pero
con auto-stacking correcto por debajo de `sm`) a 335px de ancho,
arrastrando la página entera. Mismo bug de fondo que el ya
documentado en la galería, en un grid en vez de un
`overflow-x-auto`.
- **Navbar, fila de "Departamentos"/"Todo"** (`components/layout/navbar.tsx`):
los 3 items de esa fila (botón de menú, "Departamentos", "Todo")
tenían los tres `shrink-0` — ninguno podía achicarse, así que si
los tres no entraban en 320px, desbordaban sí o sí (`min-w-0` no
aplica cuando el ítem no está pensado para achicarse). Fix:
ocultar el texto "Departamentos" en mobile (queda ícono + flecha,
con `aria-label` agregado para no perder accesibilidad), libera
espacio suficiente para que quepan los tres.
Verificado en vivo con Playwright (viewport 320×480 real, no
emulación de dispositivo con DPR) contra staging y producción
después del deploy: `document.documentElement.scrollWidth ===
clientWidth` en home y en página de producto, en ambos ambientes —
sin overflow.
@@ -0,0 +1,321 @@
# ADR 0012 — Práctica multi-cloud: GCP y Azure DevOps en paralelo al lab ZimaOS
**Estado:** aceptada — Fase 1 (Google Workspace) completa y verificada
en vivo, con un giro real: el MCP self-hosted planeado
(`taylorwilsdon/google_workspace_mcp`) quedó abandonado por un límite
real de esa librería (ver §Fase 1), reemplazado por los Connectors
nativos de claude.ai. Fases 2-4 quedan documentadas como roadmap,
explícitamente no implementadas hasta que se pidan en una sesión
futura.
**Fecha:** 2026-09-20 (Fase 1 cerrada 2026-09-21)
## Contexto
El lab GitOps de CruzCloud (ZimaOS + k3d + Gitea + Argo CD) ya prueba en
vivo, con rigor de ADR, patrones que después se pueden proponer en
trabajo real con clientes — pero corre íntegro sobre hardware propio,
sin tocar ningún proveedor de nube público. Dos motivaciones concretas
empujan a abrir una segunda línea de práctica en paralelo:
1. **Practicar herramientas que el cliente actual restringe.** Ciertas
plataformas/herramientas de nube no están disponibles en el entorno
de trabajo del cliente actual — la única forma real de ganar
experiencia con ellas es un entorno propio.
2. **Preparar propuestas de arquitectura con evidencia, no solo teoría.**
Poder decir "ya lo corrí, así se ve, esto cuesta X" en una propuesta
a un cliente vale más que una descripción de manual.
3. **Portafolio.** Los mismos ADRs, diagramas y decisiones documentadas
que ya sostiene este lab sirven como muestra de trabajo verificable.
**Alcance confirmado explícitamente por el usuario, no asumido:**
- **Entorno paralelo, no migración.** ZimaOS sigue siendo el lab
principal — nada de lo de acá reemplaza nada de lo ya construido.
- **Cuentas personales únicamente.** Nunca credenciales del cliente
actual ni de ningún cliente futuro — separación total.
- **Costo cero o mínimo**, con alertas de presupuesto como red de
seguridad desde el primer recurso pago que se cree (ver
[Análisis de costo](#analisis-de-costo-explicito) más abajo).
- Mismo rigor documental que el resto del lab: un ADR por decisión
arquitectónica real, diagramas Mermaid, cifras verificadas en vivo o
contra fuente primaria (no contra un blog de terceros sin
corroborar — ver el caso del supuesto MCP "oficial" de Google
Workspace en la [Fase 1](#fase-1-ejecutada-google-workspace-con-un-giro-real-en-la-implementacion)).
## Diagrama del lab actual (ZimaOS)
Versión simplificada, con solo los componentes relevantes para la
comparación de este ADR — el diagrama completo (con Traefik,
`serverlb`, cada `Host` de Ingress, etc.) vive en
[Visión general](../arquitectura/vision-general.md).
```mermaid
flowchart LR
User(["Usuario"]) -->|HTTPS| CF["Cloudflare Tunnel<br/>(sin puertos abiertos)"]
CF --> NPM["Nginx Proxy Manager<br/>enrutamiento por dominio"]
NPM --> K3D["k3d<br/>(cluster K8s local, Docker)"]
subgraph K3D_INT["Dentro de k3d"]
ARGO["Argo CD"] -->|sync| APPS["Apps reales<br/>(ecommerce, docs-portal)"]
GIT["Gitea<br/>(auto-alojado)"] -.->|watch repos| ARGO
end
K3D --> K3D_INT
```
**Costo real:** $0 — hardware propio (NAS ZimaOS), sin proveedor de
nube de por medio.
## Diagrama Fase 1 — entorno paralelo en GCP (VM + k3s)
**Esto NO es GKE administrado.** Es una VM `e2-micro` corriendo k3s
self-managed, elegida específicamente para mantener el costo real en
$0 — GKE (administrado, ver [Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado))
no tiene una vía 100% gratuita permanente (ver el análisis de costo
más abajo).
```mermaid
flowchart LR
User(["Usuario / Claude"]) -->|SSH / kubectl| VM["Compute Engine e2-micro<br/>Always Free — us-west1/us-central1/us-east1<br/>NO es GKE administrado"]
subgraph VM_INT["Dentro de la VM"]
K3S["k3s<br/>(single-node, self-managed)"]
ARGO2["Argo CD<br/>(instancia propia, no la de ZimaOS)"] -->|sync| APPS2["Manifiestos del nuevo repo<br/>(mismo patrón base+overlay<br/>de platform-templates)"]
end
VM --> VM_INT
GIT_EXISTENTE["Gitea existente<br/>gitea.cruzcloud.net<br/>(reusado, repo nuevo, NO uno nuevo)"] -.->|watch| ARGO2
```
**Por qué reusar el Gitea existente en vez de levantar uno nuevo en
GCP:** el objetivo es practicar el patrón GitOps completo (Kustomize
base+overlay + Argo CD sincronizando) con el mínimo de piezas nuevas —
levantar un segundo Gitea solo sumaría superficie a mantener sin
enseñar nada nuevo sobre GCP en sí. El *cómputo* (la VM, k3s) es lo que
cambia de proveedor; el origen de verdad (Git) se queda donde ya
funciona.
**Costo real:** $0 — `e2-micro` es Always Free **de forma permanente**
(no es el trial de $300/90 días), siempre que la VM viva en
`us-west1`, `us-central1` o `us-east1` y no supere 1 instancia
no-preemptible + 30 GB-mes de disco persistente estándar + 1 GB/mes de
salida hacia Norteamérica — límites reales del programa, verificados
contra la documentación oficial de Google Cloud.[^1]
## Fase futura (no implementada) — GKE real con Spot + auto-apagado
Marcada explícitamente como **fase futura, no implementada** — ver
[Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)
para cuándo se decidiría ejecutarla.
```mermaid
flowchart LR
TF["Terraform<br/>(provisiona todo)"] --> GKE["GKE Standard<br/>1 cluster zonal"]
GKE --> NP["Node pool<br/>Spot VMs (e2-small)"]
SCHED["Cloud Scheduler<br/>(cron: apagar/prender)"] -->|invoca| FN["Cloud Run/Functions<br/>job de auto-apagado"]
FN -->|resize node pool<br/>a 0 / de vuelta| NP
style GKE fill:#333,stroke:#f90,color:#fff
style NP fill:#333,stroke:#f90,color:#fff
style SCHED fill:#333,stroke:#f90,color:#fff
style FN fill:#333,stroke:#f90,color:#fff
```
### Mecanismo de auto-apagado: comparación antes de recomendar
Dos mecanismos reales existen para esto; se investigaron ambos antes de
elegir uno, sin asumir cuál es "obviamente" mejor:
| | Escalar node pool a 0 | Destruir/recrear cluster completo (`terraform apply`/`destroy` programado) |
|---|---|---|
| Qué apaga | Solo el cómputo (los nodos) | Todo, incluido el control plane |
| Ahorra el fee de control plane ($0.10/hora) | No — pero **ya está cubierto por el crédito mensual de $74.40** para un único cluster, así que no hay nada real que ahorrar ahí | Sí, pero es un ahorro sobre algo que ya es $0 |
| Tiempo de "volver a prender" | ~1-3 min (crear nodos sobre un cluster que ya existe) | ~5-10 min (crear el cluster entero de cero) |
| Piezas para automatizarlo | Cloud Scheduler → Cloud Function/Run → 1 llamada a la API de GKE (resize node pool) | Cloud Scheduler → runner con Terraform + credenciales + state remoto seguro → `apply`/`destroy` completo |
| Riesgo de drift/config perdida | Bajo — el cluster nunca se borra | Mayor — cualquier cosa hecha fuera de Terraform se pierde en cada destroy |
| Patrón documentado en la industria | Sí, es el patrón estándar (Cloud Scheduler + Pub/Sub + Cloud Function), con casos reales de ~30% de ahorro de cómputo en clusters no-críticos[^2] | Existe, pero es la opción menos común para este caso puntual |
**Recomendación (sin implementar todavía):** escalar el node pool a
0/de vuelta vía Cloud Scheduler + Cloud Function, no destruir/recrear
el cluster completo. Razón concreta: el crédito de $74.40/mes **ya
cubre el fee de control plane de un único cluster exista o no exista
un nodo adentro** — destruir el cluster entero no ahorra nada sobre
ese componente porque ya es $0, y a cambio suma la complejidad real de
manejar Terraform state de forma segura dentro de un cron job
automatizado, más un tiempo de arranque 3-5x más largo. La única razón
real para preferir destruir/recrear sería eliminar *toda* huella en
GCP entre usos (por ejemplo, para no depender de que el crédito
mensual siga existiendo) — no es el caso acá.
## Tabla comparativa
| | Lab actual (ZimaOS) | GCP Fase 1 (VM + k3s) | GCP Fase futura (GKE + Spot) |
|---|---|---|---|
| Cómputo | NAS propio, Docker/k3d | Compute Engine `e2-micro` | GKE Standard, node pool Spot `e2-small` |
| ¿Kubernetes administrado? | No (k3d local) | No (k3s self-managed) | Sí (GKE Standard) |
| Control plane | N/A (local) | N/A (k3s corre en la misma VM) | $0.10/hora — cubierto por crédito de $74.40/mes para 1 cluster |
| Cómputo de nodos | $0 (hardware propio) | $0 (Always Free permanente) | ~$7-10/mes (Spot `e2-small`, sin cubrir por ningún crédito) |
| GitOps | Argo CD + Gitea, ambos autoalojados | Argo CD propio en la VM, mismo Gitea reusado | Igual que Fase 1, sobre GKE |
| Costo mensual real | $0 | $0 | ~$7-10/mes (no $0) |
| Estado | En producción, uso diario | A ejecutar esta sesión (Parte 2/3) | Documentado, no implementado |
## Análisis de costo explícito
**No existe un GKE administrado 100% gratis de forma permanente.** El
crédito de $74.40/mes por cuenta de facturación cubre exactamente el
fee de control plane ($0.10/hora) de **un** cluster zonal Standard o
Autopilot — nunca el cómputo real. GKE Autopilot factura desde el
primer pod que se programa (vCPU/memoria/almacenamiento por pod), sin
ningún equivalente al Always Free de Compute Engine; GKE Standard sí
puede correr sobre un único nodo Spot/preemptible barato (~$7/mes para
un `e2-small`, verificado contra tarifas actuales), pero ese cómputo
nunca es gratis.[^3]
**La única ruta de costo cero real y permanente es Compute Engine
`e2-micro` (Always Free) + k3s self-managed** — exactamente la Fase 1
de este ADR. No es el trial de $300/90 días (que expira y no es
"gratis", es prepago con reloj corriendo); es un beneficio permanente
del programa de nivel gratuito de Google Cloud, activo mientras la VM
cumpla los límites de región/tamaño/tráfico ya descritos arriba.
**Activar cualquier billing account de GCP requiere tarjeta igual**,
incluso para quedarse exclusivamente en recursos Always Free — Google
verifica el medio de pago aunque el cargo real sea $0. Detalle no
obvio, verificado contra documentación oficial: **las alertas de
presupuesto no se pueden configurar mientras la cuenta esté operando
sobre crédito gratuito de prueba** — hace falta pasar la cuenta de
facturación a estado "paga" (el uso real sigue siendo $0 si no se sale
del Always Free) para que la opción de crear un presupuesto/alerta
quede disponible.[^4] **Recomendación:** hacer ese paso — pasar a
cuenta de facturación paga — como parte del setup inicial, y configurar
de inmediato una alerta de presupuesto en **$1-5 USD** como red de
seguridad, antes de crear ningún recurso más allá de lo cubierto por
Always Free.
## Secuencia de fases
Todas documentadas acá; **solo la Fase 1 se ejecuta en esta sesión.**
Las Fases 2-4 no se implementan sin pedido explícito en una sesión
futura — quedan como roadmap, no como trabajo en curso.
### Fase 1 (ejecutada) — Google Workspace, con un giro real en la implementación
**Objetivo cumplido — acceso real a Google Calendar desde Claude,
verificado en vivo — pero por un camino distinto al planeado
originalmente.** Detalle de la investigación previa (qué MCP usar,
oficial vs comunitario) — resumen: **no existe un MCP oficial de
Google Workspace** publicado por Google (se verificó explícitamente
buscando en los GitHub `google`/`googleworkspace` oficiales — no
aparece ninguno; un solo blog de terceros afirma que "ya existe uno
oficial" sin que ninguna otra fuente lo corrobore, así que se descarta
esa afirmación por no verificable). Se optó por
`taylorwilsdon/google_workspace_mcp` (comunitario, el más completo y
activamente mantenido: 120+ herramientas, OAuth 2.1, +2500 estrellas)
como primer intento, self-hosted dentro de `tmux-host`.
**Ese self-hosted quedó abandonado — límite real encontrado en la
librería, no un error de configuración.** El servidor usa FastMCP's
`GoogleProvider`/`OAuthProxy`, que construye el `redirect_uri`
enviado a Google directamente desde su propio `base_url` — **no**
desde la variable `GOOGLE_OAUTH_REDIRECT_URI` que el proyecto expone
(esa variable solo valida entradas, no arma la salida real; hallazgo
confirmado leyendo el código fuente instalado, no la documentación
pública). Y `base_url` es la misma variable de la que depende por
default `issuer_url`, que el SDK de MCP exige sea HTTPS salvo que sea
literalmente `localhost`/`127.0.0.1` (chequeo hardcodeado en
`mcp/server/auth/routes.py:validate_issuer_url`, sin variable de
entorno que lo salte). Resultado: **no hay combinación de variables
de entorno que satisfaga a la vez** "el navegador del usuario debe
poder llegar al callback" (necesita un dominio real, no `localhost`)
y "el issuer debe ser HTTPS o `localhost`" — `workspace-mcp` no
expone `issuer_url` por separado de `base_url` en su capa de
configuración (confirmado en `core/server.py`: solo pasa `base_url` a
`GoogleProvider(...)`).
**Decisión del usuario ante ese límite:** en vez de armar un proxy
HTTPS con certificado autofirmado (infraestructura extra, fricción de
certificado no confiable en cada uso) o parchear el paquete instalado
a mano (frágil, se pierde en cualquier reinstalación), se usaron los
**Connectors nativos de claude.ai** (Google Calendar, vía el
directorio de conectores de la cuenta, fuera de Claude Code por
completo) — mismo resultado práctico (Claude con acceso real al
calendario personal), infraestructura cero de nuestro lado, sin
tocar código de terceros. Contrapartida real: es una integración de
cuenta de claude.ai, no un MCP configurado dentro del proyecto
`platform-multicloud` — no aparece como herramienta en sesiones de
Claude Code (`lab`/`cloud-practice`), solo en el chat normal de
claude.ai.
**Verificado en vivo:** pregunta real en claude.ai normal ("¿qué
tengo en el calendario esta semana?") devolvió una respuesta correcta
revisando 3 calendarios reales de la cuenta (principal, Familia,
festivos de Colombia) vía la herramienta `Google Calendar: Lists
calendar events`, con razonamiento correcto sobre qué cuenta como
"esta semana" dado el día/hora real de la consulta.
### Fase 2 (documentar, no ejecutar) — Terraform aislado
Instalar el marketplace oficial de HashiCorp Agent Skills, y practicar
aprovisionando algo mínimo y desechable en GCP. Nota para cuando se
ejecute: HashiCorp también publica un **MCP Server de Terraform**
propio y oficial (`hashicorp/terraform-mcp-server`, integra con el
Terraform Registry y HCP Terraform) — es una pieza distinta a los
Agent Skills (el MCP es el "tubo" hacia herramientas reales; los
Skills son paquetes de conocimiento/instrucciones) y vale la pena
evaluar si se usan juntos o solo uno de los dos en ese momento.
### Fase 3 (documentar, no ejecutar) — Azure DevOps en paralelo
MCP oficial `microsoft/azure-devops-mcp` (confirmado como el oficial y
activamente mantenido por Microsoft, hay varios forks/alternativas
comunitarias que no son la vía recomendada). Replicar el mismo
pipeline DevSecOps que ya corre en Gitea Actions para este lab
(Gitleaks → Trivy → Semgrep → Syft → Cosign) como Azure Pipelines YAML,
en un proyecto personal — mismo objetivo de práctica que la Fase 1:
tener el patrón ya probado para proponerlo en trabajo con clientes que
usen Azure DevOps.
### Fase 4 (documentar, no ejecutar) — GKE real con Spot + auto-apagado
Solo si se decide conscientemente asumir el costo bajo pero no-cero
(~$7-10/mes, ver el análisis de costo arriba) — vía Terraform, node
pool Spot, y el mecanismo de auto-apagado recomendado en la sección de
arriba (escalar a 0, no destruir/recrear el cluster).
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| Entorno paralelo en GCP+Azure (elegido) | Practica exactamente lo que el cliente actual restringe; portafolio con evidencia real; ZimaOS sigue intacto | Dos entornos más que mantener; curva de aprendizaje de dos proveedores nuevos a la vez |
| Migrar el lab completo a un solo cloud | Un solo entorno que mantener | Pierde justo lo que se quiere conservar (costo $0, control total de hardware); no es lo que se pidió |
| Solo AWS (el "default" de la industria) | Ecosistema más grande | El cliente actual ya usa/restringe otras plataformas específicas — practicar AWS no cierra esa brecha puntual |
| GKE administrado desde el día 1 (saltear la VM self-managed) | Más parecido a "producción real" desde el inicio | Rompe el requisito de costo cero explícito del usuario — ningún tier de GKE es gratis de forma permanente |
## Decisión
Se abre una línea de práctica multi-cloud paralela al lab ZimaOS, en
cuentas personales, arrancando por Compute Engine `e2-micro` (Always
Free) + k3s self-managed en GCP como Fase 1 de cómputo, y Google
Workspace vía MCP comunitario (`taylorwilsdon/google_workspace_mcp`)
como primer MCP a configurar en esta sesión. Terraform, Azure DevOps y
GKE administrado quedan documentados como Fases 2-4, sin implementar.
## Consecuencias
- **Se gana** un segundo entorno real, documentado con el mismo rigor
de ADR que ya sostiene el lab, listo para mostrar en propuestas a
clientes — sin tocar nunca credenciales de cliente.
- **Se sacrifica/queda como deuda técnica:** la Fase 1 de GCP usa k3s
self-managed, no un Kubernetes administrado real — cualquier lección
específica de operar GKE (autoscaling nativo, node pools, IAM de
GCP) queda pendiente hasta la Fase 4, y esa fase sí tiene costo real
(~$7-10/mes) que hoy no se está pagando.
- **Para revertir:** dar de baja la VM `e2-micro` y (si se llegó a
crear) el node pool de la Fase 4 — ningún componente de este ADR
toca o depende del lab ZimaOS existente, así que no hay nada que
revertir de ese lado.
[^1]: [Compute Engine Free Tier — Google Cloud](https://cloud.google.com/free/docs/compute-getting-started), [GCP Free Tier Complete Guide 2026](https://agentdeals.dev/gcp-free-tier-2026)
[^2]: [Reduce Your GKE Bill with Cloud Scheduler by Scaling to Zero](https://chimbu.medium.com/reduce-your-gke-bill-with-cloud-scheduler-by-scaling-to-zero-during-off-peak-hours-9796bb4f7b8b), [Scaling GKE Standard Clusters to Zero](https://medium.com/@gustavo.romerobenitez_34616/scaling-gke-standard-clusters-to-zero-56bf5270510e)
[^3]: [GKE Pricing Guide 2026 — Sedai](https://sedai.io/blog/google-cloud-kubernetes-cluster-cost), [e2-small pricing — Economize](https://www.economize.cloud/resources/gcp/pricing/compute-engine/e2-small/), [Google Kubernetes Engine pricing — Google Cloud](https://cloud.google.com/kubernetes-engine/pricing)
[^4]: [Google Cloud Free Trial FAQs](https://cloud.google.com/signup-faqs), [Create, edit, or delete budgets and budget alerts — Google Cloud](https://docs.cloud.google.com/billing/docs/how-to/budgets)
@@ -0,0 +1,122 @@
# ADR 0013 — Iniciativas de clientes: Google Apps Script y AppSheet
**Estado:** aceptada — **fase prioritaria**, precede la reanudación de
las Fases 2-4 del [ADR 0012](0012-practica-multicloud-gcp-azure.md).
Scaffold del repo en curso; primeras iniciativas reales se agregan
sesión a sesión, a medida que se comparten.
**Fecha:** 2026-09-21
## Contexto
Iniciativas propias de la empresa (no del lab), trabajadas con **cuenta
personal de Google** — dos herramientas concretas: **Google Apps
Script** (código, automatizaciones) y **Google AppSheet** (apps
no-code sobre Sheets). Se abre como fase separada del ADR 0012 y con
prioridad sobre él por decisión explícita: retomar las Fases 2-4 del
lab (Terraform, Azure DevOps, GKE) espera a que esto avance primero.
**Restricción dura, no negociable:** ningún repo ni documento de esta
iniciativa contiene credenciales, datos ni información real de
cliente. Apps Script versiona código (lógica), nunca datos. AppSheet
no tiene export de código — solo se documenta desde afuera (tablas,
columnas, expresiones, capturas), nunca se copian datos reales de
ninguna hoja de cálculo al repo.
## Opciones consideradas
### Apps Script: MCP vs. `clasp`
| Opción | A favor | En contra |
|---|---|---|
| MCP para Apps Script | Interacción conversacional directa | **No existe un MCP oficial de Google.** Se verificó explícitamente buscando — solo hay servidores comunitarios (`mohalmah/google-appscript-mcp-server`, `whichguy/gas_mcp`, entre otros), ninguno publicado por Google. Se descartan sin evaluar más a fondo: no hay necesidad de agregar una dependencia comunitaria no verificada cuando existe una CLI oficial que cubre el caso de uso completo. |
| **`clasp`** (elegido) | CLI oficial de Google (`@google/clasp`), mantenida por el equipo de Apps Script, cubre push/pull/login/versiones/despliegues | Requiere login interactivo (OAuth) — mitigado: el login lo corre el dueño de la cuenta, nunca se comparte el código de autorización |
### AppSheet: qué se puede versionar
Se verificó contra la documentación oficial vigente (no se asumió):
**AppSheet no expone ningún mecanismo de export de la definición de la
app** (tablas virtuales, vistas, expresiones, automatizaciones) hacia
un formato versionable con git. La única superficie exportable es la
app en sí vía la consola de AppSheet, no un artefacto de código. Existe
un addon de terceros ("App Script Saver") pero es específico de Apps
Script, no de AppSheet, y no se evaluó como parte de esta decisión
(mismo criterio que con los MCP: no se instala nada de terceros sin
pasar primero por la verificación de publicador/permisos ya establecida
en el lab).
**Decisión sobre AppSheet:** no se intenta versionar la app —se
documenta desde afuera. La documentación (markdown + capturas) es la
única fuente versionada; la app real en la consola de AppSheet sigue
siendo la fuente de verdad operativa, con el riesgo explícito de que
la documentación quede desactualizada (ver Consecuencias).
## Decisión
Repo privado nuevo `client-initiatives` en Gitea (separado de
`apps-registry`/`platform-infra`/`scripts`/`platform-multicloud` — ni
lab GitOps ni práctica multi-cloud, es su propia categoría). Estructura:
una carpeta por iniciativa bajo `apps-script/` (proyecto `clasp`
completo, con su propio `.clasp.json`) o `appsheet/` (documentación +
plantilla de estructura vacía). Mismo criterio de versionado semántico
y modularidad que ya usa `docs-portal`.
**Flujo Apps Script:** rama `fix/*` → diff mostrado → `merge --no-ff`
a `main`. `clasp push` (subir el código a Google) es una confirmación
explícita **aparte**, nunca agrupada con el merge a git — son dos
sistemas de versionado distintos (git para el repo, Apps Script para
las versiones desplegadas) y no deben confundirse.
**Gitleaks:** se agrega al pipeline del repo nuevo. Nota real
encontrada al planificar esto: Gitleaks hoy solo corre en
`apps-registry` (3 workflows) — `platform-infra` y `scripts` no tienen
Gitea Actions configurado en absoluto, no hay "los otros tres repos con
Gitleaks" como se asumió al principio. Se replica el step exacto que
ya usa `apps-registry` (Gitleaks v8.21.2, `--no-git --redact`, reporte
JSON) por ser el único precedente real en el lab.
## Fases
### Fase A — Google Apps Script
1. Scaffold del repo `client-initiatives` (estructura, Gitleaks,
convenciones documentadas).
2. Login de `clasp` (`clasp login --no-localhost`) corrido por el
dueño de la cuenta — verificación posterior de que
`~/.clasprc.json` queda fuera de cualquier repo y con `chmod 600`.
3. Primeras iniciativas: pendientes de compartir, se agregan una por
una bajo `apps-script/<slug>/` a medida que llegan.
### Fase B — Google AppSheet
1. Por cada app existente: documentar tablas, columnas y expresiones
en markdown, más capturas del estado actual, bajo
`appsheet/<slug>/`.
2. Plantilla de estructura de datos vacía si aplica (schema, nunca
filas reales).
3. Limitación dejada explícita en cada README de iniciativa: sin diff
automático posible entre esta doc y la app real.
## En pausa mientras esta fase esté activa
Por decisión explícita del usuario, prioridad sobre lo ya documentado
en el ADR 0012 y el resto del lab:
- **Swap de `tmux-host`** — no se corre en esta fase.
- **[Fase 4 del ADR 0012](0012-practica-multicloud-gcp-azure.md#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)**
(GKE real con Spot + auto-apagado) — sigue solo documentada.
- **Prueba del skill `frontend-design`** (pendiente desde la
instalación de plugins del 2026-09-20) — no se ejecuta.
Tampoco se toca NPM, `cloudflared`, ni `lab-agent` durante esta fase.
## Consecuencias
- Se gana un espacio versionado y auditado (Gitleaks) para
automatizaciones reales de la empresa, sin mezclar nada con el lab
GitOps ni con la práctica multi-cloud.
- Se acepta como deuda consciente que la documentación de AppSheet
puede desincronizarse de la app real — no hay forma técnica de
evitarlo, solo disciplina de actualizarla.
- Revertir esta decisión implicaría: eliminar el repo
`client-initiatives` (las iniciativas seguirían existiendo en Google,
solo se perdería el historial versionado y la documentación externa).
@@ -0,0 +1,385 @@
# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26)
| | |
|---|---|
| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z |
| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) |
| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic |
| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa |
## Contexto / disparador
El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las
sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai.
Parecía un problema del contenedor, así que se eliminó `tmux-host` para
recrearlo desde cero.
En realidad ese síntoma tenía una causa que no tenía nada que ver con el
contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al
recrear el contenedor, la mitad de la noche se fue en resolver tres fallos
reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada
uno se encontró recién después de resolver el anterior. Recién al final
apareció la causa original.
!!! info "Fuentes de la evidencia"
Este playbook se escribió desde dentro del `tmux-host` ya recuperado.
Los datos marcados **(verificado)** se confirmaron contra el estado
real del sistema al escribirlo: `docker inspect`, `docker images`,
`health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los
marcados **(reportado)** vienen del relato de la sesión de
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
transcript ni historial de shell que se pueda revisar.
---
## Causa a — Contenedor recreado sin los volúmenes de autenticación
**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el
diálogo de confianza del folder. `--continue` no encontraba ninguna
conversación previa y arrancaba una en blanco.
**Diagnóstico.** El `docker run` que se usó para recrear el contenedor
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)
**(reportado)**. Faltaban justamente los dos de autenticación y estado
de Claude Code:
| Volumen faltante | Qué se perdía |
|---|---|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria |
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control |
Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
se reconstruyó varias veces con el mismo comando incompleto antes de
comparar contra el doc de arquitectura **(reportado)**. `health.log`
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**.
**Fix.** Recrear el contenedor con el `docker run` completo de la
sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes
van juntos, sin excepciones.
**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el
contenedor final (creado `2026-09-26T06:10:55Z`, imagen
`tmux-host:2026-09-26-fix-real`) lista los 5 mounts:
```
/var/run/docker.sock -> /var/run/docker.sock
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
/DATA -> /DATA
/media/HDD-Storage -> /media/HDD-Storage
```
La sesión `lab` retomó la conversación anterior con `--continue`, sin
pedir de nuevo la confianza del folder.
!!! warning "Lección"
**Antes de improvisar un `docker run` de memoria, leer siempre el doc
de arquitectura que ya existe.** El comando completo y el motivo de
cada volumen ya estaban documentados en `sesion-tmux-persistente.md`.
El tiempo perdido reconstruyendo con un comando incompleto salió de no
abrirlo primero.
---
## Causa b — `set -e` heredado mataba al supervisor del healthcheck
**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo
relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor
seguía vivo sin healthcheck.
**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El
supervisor corre como función en un subshell (`supervise_healthcheck &`)
y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de
cero, `set -e` mataba al propio subshell del supervisor *en esa misma
línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban.
Es decir, el supervisor moría justo en el único caso para el que existía.
**Paso en falso: el fix que nunca llegó al disco.** La primera
"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la
imagen se reconstruyó dos veces con el contenido viejo **(reportado)**.
La evidencia sigue en el host **(verificado)**: las etiquetas
`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2`
apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las
capas porque el archivo no había cambiado. Recién después de confirmar
con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se
construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z).
**Fix.** En `scripts/tmux-host/entrypoint.sh`:
```diff
-set -eu
+set -u
-/usr/local/bin/healthcheck.sh &
+supervise_healthcheck() {
+ while true; do
+ /usr/local/bin/healthcheck.sh
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
+ sleep 10
+ done
+}
+supervise_healthcheck &
exec tail -f /dev/null
```
**Validación en vivo (verificado).** `health.log` registra el supervisor
actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró
el aviso y se relanzó solo 10 segundos después:
```
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
```
!!! warning "Lección"
**Verificar el archivo en disco (`cat`, `git diff`) ANTES de
reconstruir la imagen.** No hay que asumir que el editor guardó el
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
no tuvo cambios.
---
## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic
**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al
arrancar con `Unable to connect to Anthropic services` a los ~10 s.
**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**:
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
instante con `Network unreachable`.
3. El fallback automático a IPv4 terminaba funcionando, pero la conexión
completa tardaba **~14 s**. Eso es más que el timeout interno de
Claude Code (~10 s), así que el CLI se rendía antes de que el
fallback llegara a conectar.
**Primer intento: parche parcial.**
`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
**(reportado)**, y además solo corregía el proceso de Node, no la red del
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
`NODE_OPTIONS` en su entorno **(verificado)**.
**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS,
en caliente y persistente:
```bash
# En caliente
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
```
Esto se tiene que hacer **en el host, no dentro del contenedor**.
`tmux-host` corre con `--network host`, así que comparte el network
namespace del NAS. Docker no permite fijar sysctls de red
(`--sysctl net.*`) en un contenedor con `--network host`, porque
cambiarían la red de todo el host.
**Validación en vivo (verificado, 2026-09-26 ~06:40Z):**
```
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
$ getent ahosts api.anthropic.com
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
```
`/etc/sysctl.conf` en el host contiene las dos líneas (escrito
2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura,
pero `/etc` es un overlay con capa de escritura en
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
persiste.
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
Resultado **(verificado)**:
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
(el overlay de `/etc` persiste).
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
archivo existía y **nunca se aplicó** al arrancar.
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
fallando (`Could not connect to server`). Si el router vuelve a entregar
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
no porque estuviera corregido.
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
Hace falta un contenedor privilegiado de un solo uso en la red del host,
o `sudo sysctl -p` directo en el host:
```bash
docker run --rm --privileged --network host \
-v /etc/sysctl.d:/etc/sysctl.d:ro \
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
```
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
!!! warning "Lección"
"El archivo de configuración persistió" no es lo mismo que "la
configuración se aplica al arrancar". La única validación real de
algo persistente es **reiniciar y medir el valor efectivo**, no
revisar que el archivo siga ahí.
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
`tentative`), que no sale a internet y no afecta.
Sigue sin verificarse si una **actualización de ZimaOS** pisa
`upper_etc`. Después de cada update del SO, revisar
`sysctl net.ipv6.conf.all.disable_ipv6`.
---
## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática
**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas
*seguían* sin aparecer en claude.ai. Este era el síntoma original de la
noche.
**Diagnóstico (verificado).** Ambos procesos `claude --remote-control
--continue` estaban corriendo (`ps`), y el healthcheck había recreado
las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
causa:
- Sesión `lab`: `Login expired · Please run /login`
- Sesión `cloud-practice`:
`Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control`
El token OAuth de Claude Code había vencido. No había ningún problema de
red, de contenedor ni de volúmenes. **Esta causa no tenía relación con
ninguna de las tres anteriores.** Eliminar el contenedor nunca podría
haberla resuelto.
**Fix.**
1. `/login` en **una** sesión. Las credenciales viven en
`/root/.claude/.credentials.json`, que es el volumen compartido por
todas las sesiones del contenedor.
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
abierta. Un proceso `claude` que ya estaba corriendo **no** se
reconecta solo al renovarse el token. Hay que reactivarlo en cada uno.
Para una sesión a la que no estás adjunto:
```bash
tmux send-keys -t cloud-practice '/remote-control' Enter
# aparece el menú con "1. Enable Remote Control" ya seleccionado
tmux send-keys -t cloud-practice Enter
```
**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a
mostrar `/remote-control is active · Continue here, on your phone, or at
https://claude.ai/code/session_…`, y en `lab` se activó con
`/remote-control` después del `/login`. Las dos sesiones volvieron a
aparecer en claude.ai.
!!! tip "Diferencia clave con las otras causas"
El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue`
retoma la conversación. Un **token vencido no se recupera solo**: hace
falta que una persona se reautentique (`/login`) y reactive Remote
Control (`/remote-control`) en cada sesión abierta. Recrear el
contenedor no lo arregla. Con los volúmenes bien montados, el
contenedor nuevo lee el mismo token vencido.
---
## Cronología resumida
Horas en UTC, según `health.log`, `docker images` y los timestamps de
archivos cuando existen.
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|---|---|---|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — |
| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No |
| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró |
| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No |
| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No |
| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No |
| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No |
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No |
| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado |
| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** |
**Camino corto para la próxima vez.** Si "no aparecen las sesiones
remotas", **antes de tocar el contenedor** mirar el pane de cada sesión:
```bash
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
```
Si aparece `Login expired` u `OAuth token unavailable`, la solución es
`/login` + `/remote-control`. No hace falta nada más.
## Lección general
Un solo síntoma ("no toma las sesiones remotas") puede tener **varias
causas independientes superpuestas**. En este caso, tres de ellas las
provocó el propio intento de arreglo. Que la primera causa real
encontrada se haya resuelto no significa que era *la* causa del síntoma.
Lo que destrabó el incidente fue seguir verificando con evidencia
concreta después de cada fix, en vez de asumir que "ya quedó": el
contenido de los panes, `health.log`, `docker inspect`, `docker images`
(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó
con un dato observable, no por deducción.
## Pendientes
Todos cerrados el 2026-09-26:
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
`fix-real` en producción.
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
que ahora sí se aplica solo (ver la causa c).
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
y que `disable_ipv6` siga en `1`.
+4
View File
@@ -86,6 +86,7 @@ nav:
- Flujo GitOps: arquitectura/gitops-flujo.md
- Agente de monitoreo: arquitectura/agente-monitoreo.md
- Sesión tmux persistente: arquitectura/sesion-tmux-persistente.md
- Plugins y subagentes: arquitectura/plugins-y-subagentes.md
- Glosario: arquitectura/glosario.md
- Decisiones (ADRs):
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
@@ -99,6 +100,8 @@ nav:
- "0009 · Checkout real": decisiones/0009-checkout-real.md
- "0010 · Pago y envío reales": decisiones/0010-pago-envio-reales-y-storefront.md
- "0011 · Ambiente de Staging": decisiones/0011-ambiente-staging.md
- "0012 · Práctica multi-cloud GCP/Azure": decisiones/0012-practica-multicloud-gcp-azure.md
- "0013 · Iniciativas de clientes": decisiones/0013-iniciativas-clientes.md
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -111,6 +114,7 @@ nav:
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
- "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
- Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.267
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.283
# El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
@@ -26,11 +26,11 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.266
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.286
env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION
value: v1.0.266
value: v1.0.286
- name: HEADLESS_PROVIDER
value: medusa
# Backend COMPARTIDO con Producción a propósito (decisión del
@@ -3,12 +3,13 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
import { useLocationStore } from "@/store/location-store";
const SELECT_CLASS =
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
@@ -72,12 +73,24 @@ export function CheckoutClient() {
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState("");
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
const savedDepartmentId = useLocationStore((state) => state.departmentId);
const savedCityId = useLocationStore((state) => state.cityId);
const locationHydrated = useLocationStore((state) => state.hasHydrated);
const saveLocation = useLocationStore((state) => state.setLocation);
useEffect(() => {
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
}, [locationHydrated, savedDepartmentId, savedCityId]);
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const currency = items[0]?.product.currency;
async function submitAddress(event: FormEvent) {
event.preventDefault();
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
saveLocation(selectedDepartamento.id, selectedCiudad.id);
setLoading(true);
setError(null);
try {
@@ -34,7 +34,16 @@ const SHOP_LINKS = [
];
export function Footer() {
return <footer className="bg-ari-marino text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex flex-col items-start gap-3"><img src="/images/brand/logo-ari.svg" alt="ARI shopping" className="h-11 w-auto" width={200} height={64} />{/* eslint-disable-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}<p className="text-xs text-white/60">Tesoros para cada aventura.</p></div><div className="mt-5 flex gap-3">{SOCIAL_LINKS.map(({ label, href, Icon }) => <a key={label} href={href} target="_blank" rel="noopener noreferrer" aria-label={label} className="text-white/80 transition hover:text-ari-coral-claro"><Icon /></a>)}</div></div><FooterColumn title="Compra" links={SHOP_LINKS} /><FooterColumn title="Ayuda" links={HELP_LINKS} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-ari-coral px-4 text-sm font-black text-white">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless {appVersion} · GitOps</div></footer>;
return <footer className="bg-ari-marino text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex flex-col items-start gap-3"><img src="/images/brand/logo-ari.svg" alt="ARI shopping" className="h-11 w-auto" width={200} height={64} />{/* eslint-disable-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}<p className="text-xs text-white/60">Tesoros para cada aventura.</p></div><div className="mt-5 flex gap-3">{SOCIAL_LINKS.map(({ label, href, Icon }) => <a key={label} href={href} target="_blank" rel="noopener noreferrer" aria-label={label} className="text-white/80 transition hover:text-ari-coral-claro"><Icon /></a>)}</div></div><FooterColumn title="Compra" links={SHOP_LINKS} /><FooterColumn title="Ayuda" links={HELP_LINKS} />{/* min-w-0: sin esto, un grid item de texto toma como minimo su ancho
max-content (una sola linea, "Recibe lanzamientos y promociones
especiales." sin wrappear, ~335px) en vez de dejarse achicar y
wrappear dentro del track del grid -- confirmado en vivo 2026-09-20
contra un viewport real de 320px: la columna entera del footer (y
con ella, la pagina completa) quedaba forzada a 335px de ancho por
este unico parrafo. Mismo bug de fondo que el ya documentado en
product-gallery.tsx/app/product/[slug]/page.tsx, en un grid en vez
de un overflow-x-auto. */}
<div className="min-w-0"><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-ari-coral px-4 text-sm font-black text-white">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless {appVersion} · GitOps</div></footer>;
}
function FooterColumn({ title, links }: { title: string; links: { label: string; href: string }[] }) {
@@ -0,0 +1,100 @@
"use client";
import { MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { useLocationStore } from "@/store/location-store";
const selectClass =
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
export function LocationPicker({ firstName }: { firstName: string | null }) {
const departmentId = useLocationStore((state) => state.departmentId);
const cityId = useLocationStore((state) => state.cityId);
const hydrated = useLocationStore((state) => state.hasHydrated);
const setLocation = useLocationStore((state) => state.setLocation);
const [open, setOpen] = useState(false);
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
const [draftCity, setDraftCity] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
useEffect(() => {
if (!open) return;
setDraftDepartment(departmentId);
setDraftCity(cityId);
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, departmentId, cityId]);
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
>
<MapPin className="size-5 shrink-0 text-ari-coral" />
<span className="leading-tight">
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
<div className="mt-4 grid gap-3">
<select
aria-label="Departamento"
className={selectClass}
value={draftDepartment ?? ""}
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
>
<option value="">Departamento</option>
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select
aria-label="Ciudad"
className={selectClass}
value={draftCity ?? ""}
disabled={!draftDepartment}
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
>
<option value="">Ciudad o municipio</option>
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Button
variant="primary"
disabled={!draftDepartment || !draftCity}
onClick={() => {
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
setOpen(false);
}}
>
Guardar ciudad
</Button>
</div>
</div>
)}
</div>
);
}
+260 -33
View File
@@ -2,9 +2,10 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { LocationPicker } from "@/components/layout/location-picker";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
@@ -47,9 +48,25 @@ export function Navbar({ products, categories }: { products: Product[]; categori
[products],
);
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
// se expande recien por debajo de 40px) porque achicar un header sticky
// cambia la altura del documento -- con un solo umbral el header
// parpadea entre los dos estados justo en el borde.
const [compact, setCompact] = useState(false);
useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
setCompact((current) => (current ? y > 40 : y > 160));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm">
<header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
</div>
@@ -61,16 +78,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
angosto/recortado). En desktop (lg+) se mantiene la fila unica
original -- ahi sobra espacio y partirla en dos solo alargaria
el header sin necesidad. */}
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
<div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
{/* Isotipo circular con icono + wordmark integrados (como en la
version previa a ADR 0008), pedido explicito del usuario en
vez del lockup rectangular. logo-ari.svg (rectangular) se
mantiene para el footer. */}
<Link href="/" aria-label="ARI shopping" className="shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
</Link>
<div className="hidden lg:block">
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
</div>
<div className="hidden lg:block">
<PredictiveSearch />
</div>
@@ -84,6 +104,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
Cristian / Cuenta y listas" de Amazon): en mobile no hay
lugar y los iconos solos ya se entienden. */}
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
</button>
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">Cambios y</span>
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
</Link>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
<Heart />
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
@@ -100,8 +131,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</div>
</div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
</Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -142,16 +173,26 @@ function useCategoryNavigationState() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const isCatalog = pathname === "/catalog";
return {
isAllActive: isCatalog && !currentCategory,
isAllActive: isCatalog && !currentCategory && !currentCollection,
isActive: (name: string) => isCatalog && currentCategory === name,
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
};
}
function pillClass(active: boolean): string {
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
const INACTIVE_NAVIGATION: NavigationState = {
isAllActive: false,
isActive: () => false,
isCollectionActive: () => false,
};
function pillClass(active: boolean, dense = false): string {
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
active
? "bg-white text-ari-marino shadow-lg shadow-black/10"
: "text-white/90 hover:bg-white/10 hover:text-white"
@@ -166,26 +207,26 @@ function mobileLinkClass(active: boolean): string {
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void };
function DesktopCategoryNavigation(props: NavBarProps) {
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
function DesktopCategoryNavigationFallback(props: NavBarProps) {
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
// (cada una con su desplegable de subcategorias) -- antes solo se veian
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
const { isActive, isAllActive, isCollectionActive } = state;
const anyCollectionActive = collections.some(isCollectionActive);
return (
<nav className="bg-ari-marino text-white">
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
@@ -194,24 +235,201 @@ function CategoryNavBar({
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-2 shrink-0 text-white hover:bg-white/10"
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
>
<Menu />
</Button>
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
<div className="shrink-0 xl:hidden">
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
</div>
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
<div className="hidden items-center gap-0.5 xl:flex">
{tree.map((root) => {
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
if (root.children.length === 0) {
return (
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
{root.name}
</Link>
);
}
return (
<NavDropdown key={root.id} label={root.name} active={active}>
{(close) => (
<ul className="grid min-w-56 gap-1">
{root.children.map((child) => (
<li key={child.id}>
<Link
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={close}
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
<li className="mt-1 border-t border-white/10 pt-2">
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
Ver todo en {root.name}
</Link>
</li>
</ul>
)}
</NavDropdown>
);
})}
</div>
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
{collections.length > 0 && (
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
{(close) => (
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
{collections.map((collection) => (
<Link
key={collection}
href={`/catalog?collection=${encodeURIComponent(collection)}`}
prefetch={false}
aria-current={isCollectionActive(collection) ? "page" : undefined}
onClick={close}
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
>
{collection}
</Link>
))}
</div>
)}
</NavDropdown>
)}
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
de ayuda que la tienda ya tiene (antes solo en el footer). */}
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
{(close) => (
<ul className="grid min-w-60 gap-1">
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
<li className="mt-1 border-t border-white/10 pt-2">
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
</li>
</ul>
)}
</NavDropdown>
</div>
</div>
</nav>
);
}
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
// alternara, cerraria el menu que se acaba de abrir por hover y para el
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
// touch (sin hover) el click sigue alternando normal.
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
// solo en tablets (iPad horizontal cae en el rango del mega menu).
// Reportado por frontend-critic con log de eventos del navegador.
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
if (open && Date.now() - hoverOpenedAt < 600) return true;
return !open;
}
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
function NavDropdown({
label,
labelClassName,
ariaLabel,
active,
align = "left",
icon,
children,
}: {
label: string;
labelClassName?: string;
ariaLabel?: string;
active: boolean;
align?: "left" | "right";
icon?: ReactNode;
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 200);
};
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => () => cancelClose(), []);
return (
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
type="button"
aria-label={ariaLabel}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
>
{icon}
<span className={labelClassName}>{label}</span>
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
</div>
)}
</div>
);
}
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
@@ -251,20 +469,29 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
const openNow = () => {
cancelClose();
if (!open) hoverOpenedAt.current = Date.now();
setOpen(true);
};
return (
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
aria-label="Departamentos"
className={`flex items-center gap-1.5 ${pillClass(open)}`}
>
<LayoutGrid className="size-4" />
Departamentos
{/* Texto oculto en mobile: los 3 items de esta fila (menu, este
boton, "Todo") tienen shrink-0 -- ninguno se achica, asi que si
los tres no entran, desbordan la pagina entera (confirmado en
vivo 2026-09-20 contra 320px real: 14px de overflow horizontal
con el texto visible). No es un caso de min-w-0 faltante (ese
fix asume que el item PUEDE achicarse) -- ocultar la palabra
mas larga de la fila es lo que de verdad libera espacio. */}
<span className="hidden sm:inline">Departamentos</span>
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
@@ -0,0 +1,126 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
// Galería deslizable de la tarjeta de producto: permite ver todas las
// imágenes desde el listado sin entrar al producto. En móvil se desliza
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
export function ProductCardGallery({
images,
name,
href,
priority = false,
}: {
images: string[];
name: string;
href: string;
priority?: boolean;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const total = images.length;
const goTo = (target: number) => {
const node = scroller.current;
if (!node) return;
const next = Math.max(0, Math.min(total - 1, target));
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
};
const onScroll = () => {
const node = scroller.current;
if (!node || node.clientWidth === 0) return;
setIndex(Math.round(node.scrollLeft / node.clientWidth));
};
return (
<>
<div
ref={scroller}
onScroll={total > 1 ? onScroll : undefined}
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((src, slide) => (
<Link
key={src}
href={href}
prefetch={false}
className="relative block h-full w-full shrink-0 snap-center snap-always"
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
tabIndex={slide === 0 ? 0 : -1}
>
{/* object-contain (no object-cover): las fotos secundarias del
catálogo no son cuadradas (verticales tipo 933x1500 u
horizontales) y cover les cortaba cabeza, pies o bordes
-- reportado en vivo con Tristeza y Buzz. Mismo criterio que
product-gallery.tsx; el letterbox queda del bg-white de la
tarjeta. */}
{/* unoptimized desde la 2da imagen: pasar todas por el
optimizador de Next multiplica el trabajo de sharp dentro
del pod (~58 imagenes en el catalogo vs. 18 antes) -- una
carga en rafaga de todas las galerias dejo un pod de prod
OOMKilled (limite 768Mi) el 2026-09-27. Los originales de
media.cruzcloud.net pesan ~165 KB promedio y son lazy: solo
se bajan al deslizar. La 1ra sigue optimizada como antes. */}
<Image
src={src}
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
fill
unoptimized={slide > 0}
priority={priority && slide === 0}
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
className="object-contain transition duration-500 group-hover:scale-[1.035]"
/>
</Link>
))}
</div>
{total > 1 && (
<>
<button
type="button"
aria-label="Imagen anterior"
disabled={index === 0}
onClick={() => goTo(index - 1)}
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Imagen siguiente"
disabled={index === total - 1}
onClick={() => goTo(index + 1)}
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronRight className="size-5" />
</button>
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
{images.map((src, dot) => (
<button
key={src}
type="button"
aria-label={`Ver imagen ${dot + 1} de ${total}`}
aria-current={dot === index}
onClick={() => goTo(dot)}
className="grid h-5 place-items-center px-[3px]"
>
<span
className={`block h-1.5 rounded-full transition-all ${
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
}`}
/>
</button>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -1,9 +1,9 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react";
import { ProductCardGallery } from "@/components/product/product-card-gallery";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
@@ -22,6 +22,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null;
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
@@ -37,7 +40,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
return (
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
<div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
<div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
@@ -179,7 +179,18 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
style={{
opacity: hoverZoom && !hoverZoom.showPanel ? 1 : 0,
backgroundImage: `url(${selected})`,
// Comillas dentro de url() a proposito: sin ellas, un
// espacio o parentesis literal en el nombre de archivo
// (ej. "descarga (4)-xxx.png", el patron por defecto de
// Chrome al descargar una imagen sin renombrarla -- asi
// quedan las subidas manuales, a diferencia de las
// importadas por CSV) rompe la sintaxis de url() sin
// comillas -- el navegador descarta el valor completo en
// silencio (backgroundImage queda "none"). Confirmado en
// vivo 2026-09-20: reportado por el usuario como "el zoom
// interactivo no se visualiza" solo en fotos subidas a
// mano por el admin, nunca en las de CSV.
backgroundImage: `url("${selected}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "center",
@@ -217,7 +228,9 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
top: hoverZoom.panelTop,
width: hoverZoom.panelSize,
height: hoverZoom.panelSize,
backgroundImage: `url(${selected})`,
// Ver el comentario en el overlay in-place de arriba -- mismo
// fix, mismo motivo (comillas necesarias dentro de url()).
backgroundImage: `url("${selected}")`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
@@ -25,14 +25,14 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.263
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.286
env:
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
# que bumpea el tag de la imagen -- el footer la lee
# (process.env.APP_VERSION) para mostrar la versión real
# desplegada en vez de un "v4.1" fijo.
- name: APP_VERSION
value: v1.0.263
value: v1.0.286
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
@@ -111,14 +111,26 @@ async function medusaFetch<T>(
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
// Sin next.revalidate/tags a proposito (reportado por el usuario
// 2026-09-20: imagenes editadas a mano en Medusa no se reflejaban en
// el storefront, pero por CSV si). Causa real: frontend-deploy corre 2
// replicas sin cacheHandler compartido -- cada pod tenia su propio
// cache en memoria de este fetch (next.revalidate:60), y el POST de
// /api/revalidate (revalidateTag) del subscriber de Medusa solo le
// llegaba a UNO de los dos pods via el Service, dejando al otro
// sirviendo la respuesta vieja hasta que el TTL expirara solo. Una
// edicion puntual + revisar al toque tenia ~50% de chance de pegarle
// al pod stale; una importacion CSV (minutos de duracion) le daba
// tiempo de sobra al TTL para expirar en ambos pods, disimulando el
// mismo bug. Las paginas que consumen esto ya son force-dynamic /
// dinamicas por diseño (ver comentario en app/page.tsx y
// app/product/[slug]/page.tsx) -- este next.revalidate competia contra
// esa intencion en vez de cumplirla. Sin este bloque, Next 15 no
// cachea el fetch por default dentro de una ruta dinamica.
const response = await fetch(url, {
headers: publishableKey
? { "x-publishable-api-key": publishableKey }
: undefined,
next: {
revalidate: 60,
tags: ["medusa-products"],
},
});
if (!response.ok) {
@@ -0,0 +1,34 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Ciudad de envio elegida desde el header ("Enviar a ... · Cali"), mismo
// patron persistido que wishlist/cart. Se guardan los ids DIVIPOLA (no
// texto libre, ver lib/data/colombia.ts) para que el checkout arranque con
// el mismo departamento/ciudad ya seleccionados.
interface LocationState {
departmentId: number | null;
cityId: number | null;
hasHydrated: boolean;
setHasHydrated: (value: boolean) => void;
setLocation: (departmentId: number, cityId: number) => void;
}
export const useLocationStore = create<LocationState>()(
persist(
(set) => ({
departmentId: null,
cityId: null,
hasHydrated: false,
setHasHydrated: (value) => set({ hasHydrated: value }),
setLocation: (departmentId, cityId) => set({ departmentId, cityId }),
}),
{
name: "ari-shopping-location-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ departmentId: state.departmentId, cityId: state.cityId }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
},
),
);