Compare commits

...
Author SHA1 Message Date
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
devopsandClaude Sonnet 5 291db05a5e docs(tmux-host): documentar fix de Remote Control, --continue y patron de swap
Agrega tres gotchas nuevos a sesion-tmux-persistente.md, mismo estilo
que los existentes:

- Remote Control nunca se activaba solo: faltaba el flag
  --remote-control en launch_claude_in_session().
- Cada recreacion empezaba una conversacion en blanco: se agrego
  --continue al comando de lanzamiento.
- Actualizar tmux-host en caliente mata al proceso que hace el swap
  (corre dentro del propio contenedor): documentado el workaround de
  contenedor sibling via docker.sock.

Tambien actualiza la tabla "Que sobrevive a que" con una columna de
continuidad de conversacion, y el comando citado en la seccion de
healthcheck automatico.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 15:56:47 +00:00
gitea-actions 73ff01c84a chore(gitops): deploy Docs Portal v1.0.267 [skip ci] 2026-09-19 20:35:39 +00:00
gitea-actions 5933279713 chore(gitops): deploy Medusa v1.0.265 [skip ci] 2026-09-19 20:28:08 +00:00
devopsandClaude Sonnet 5 a77a6f2fd0 docs(tmux-host): documentar gotcha de reinicio completo del NAS (2026-09-19)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 34s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m48s
Tras un corte de luz real, la sesion tmux "lab" se recreaba sola pero
Claude Code nunca arrancaba adentro -- distinto al caveat de persistencia
de sesion ya documentado (ese es "el proceso muere, el contenedor sigue
vivo"; esto es "el host completo se apaga y arranca de cero"). Documenta
las dos causas encontradas (claude nunca se lanzaba tras recrear la
sesion; riesgo de carrera con el mount de /media/HDD-Storage) y el fix
aplicado en scripts/tmux-host/healthcheck.sh.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 20:16:13 +00:00
devops e3c27de736 chore: release v1.0.266 [skip ci] 2026-09-19 20:00:37 +00:00
devopsandClaude Sonnet 5 cc7ddddf58 fix: usar normalizeTitleForMatch en import de productos + notFound() en generateMetadata
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 33s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 20m5s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 27m10s
El importador de commerce-backend definia normalizeTitleForMatch (colapsa
guion largo/comillas tipograficas) pero nunca la usaba en el matching de
producto/categoria/coleccion -- seguia comparando con toLowerCase().trim()
plano, por eso un re-import de "Toy Story 5 - Woody" con guion largo pegado
desde Excel volvio a crear un duplicado en vez de actualizar el existente.

En el storefront, cualquier slug de producto inexistente devolvia HTTP 200
en vez de 404 (confirmado con un slug inventado): el Suspense del navbar
hace que el layout empiece a stream-ear la respuesta con status 200 antes
de que notFound() se ejecute dentro de la pagina. Se mueve la verificacion
a generateMetadata, que se resuelve completo antes de iniciar el stream.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-19 19:38:07 +00:00
gitea-actions 7dd2c139cb chore(gitops): deploy Docs Portal v1.0.264 [skip ci] 2026-09-14 05:06:42 +00:00
devopsandClaude Sonnet 5 fe4ed8005a docs: ADR 0011 (ambiente de Staging) + notas de la sesión 2026-09-13/14
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 27s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m1s
Documenta el ambiente de Staging (staging.cruzcloud.net, promoción
manual, footer con version real), y agrega a notas-sueltas la
correccion de bodega Cali, redes sociales reales, rediseño de galeria
estilo Amazon, la leccion de min-w-0 en CSS Grid, y la remocion
definitiva de la barra flotante de carrito en mobile. De paso, suma a
la nav el ADR 0010 que existía en el repo pero nunca se había
enlazado.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 05:00:19 +00:00
devopsandClaude Sonnet 5 009d5c63df chore: promote v1.0.263 to production
Incluye: footer con version real via APP_VERSION, y remocion de la
barra flotante de "Anadir al carrito" en mobile -- ambos verificados
en Staging antes de promover.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 04:23:29 +00:00
devops 4148f14128 chore: release v1.0.263 [skip ci] 2026-09-14 04:16:32 +00:00
devopsandClaude Sonnet 5 b3b7ecc77a feat(ecommerce): version real del footer via APP_VERSION
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m11s
El footer mostraba "v4.1" fijo -- inutil para distinguir a simple
vista si Staging y Produccion estan en la misma version. Ahora lee
process.env.APP_VERSION, que el propio CD (.gitea/workflows/build.yaml)
bumpea junto con el tag de la imagen en cada auto-deploy a Staging.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 04:04:09 +00:00
devops d3e0d78314 chore: release v1.0.262 [skip ci] 2026-09-14 03:46:45 +00:00
devopsandClaude Sonnet 5 5c79cc3345 fix(ecommerce): saca la barra flotante de "Añadir al carrito" en mobile
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 15m20s
Pedido del usuario con capturas reales: la barra fija seguía
superponiéndose sobre contenido de abajo al scrollear (ej. sobre la
tarjeta de "También te puede interesar"), incluso después de varias
vueltas ajustando cuándo debía aparecer/ocultarse. En vez de seguir
parchando esa lógica, se saca la barra flotante por completo -- queda
un solo botón "Añadir al carrito", visible en su lugar normal dentro
de la página también en mobile (sin duplicar), igual que la mayoría de
tiendas reales.

Se borra components/product/mobile-add-to-cart.tsx (ya sin uso) y el
id="product-buy-button"/id="recommendations-section" que solo existían
para el IntersectionObserver de ese componente.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 03:30:55 +00:00
devops 972b11772a chore: release v1.0.261 [skip ci] 2026-09-14 02:56:44 +00:00
devopsandClaude Sonnet 5 90176e36fc feat(ecommerce): ambiente de Staging (staging.cruzcloud.net)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m42s
Decisión del usuario 2026-09-14: dejar de validar cambios directo
contra shop.cruzcloud.net (producción real).

- workloads/ecommerce-staging/: overlay del frontend, mismo Dockerfile
  e imagen que Producción, backend compartido (Medusa/Postgres/
  Meilisearch de "ecommerce" via FQDN cross-namespace
  medusa-svc.ecommerce.svc.cluster.local -- decisión del usuario, evita
  duplicar el consumo de la NAS con un backend propio para Staging).
  1 réplica en vez de 2, resources mas chicos.
- apps/ecommerce-staging-app.yaml + ecommerce-staging-governance-app.yaml:
  Applications de Argo CD para el workload y la governance (en
  platform-infra), mismo patrón que ecommerce-app/ecommerce-governance.
- Ingress en staging.cruzcloud.net -- cubierto por el wildcard DNS
  *.cruzcloud.net que ya existe en Cloudflare, sin cambios de DNS.
- .gitea/workflows/build.yaml: MANIFEST_FILE/KUSTOMIZE_DIR pasan a
  apuntar a Staging -- todo push a main promueve ahí automáticamente
  desde ahora. Promover a Producción (workloads/ecommerce, sin cambios
  en este commit) queda como acción manual aparte, ver
  scripts/promote-to-production.sh.

Secrets commerce-storefront y gitea-registry-secret ya copiados a mano
al namespace ecommerce-staging (no versionados, mismo criterio que el
resto de los secrets reales del repo).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 02:46:25 +00:00
devops 5b8bac5ef1 chore: release v1.0.260 [skip ci] 2026-09-14 02:20:00 +00:00
devopsandClaude Sonnet 5 545062c3ef fix(ecommerce): imagen principal cortaba cabeza + fuente grande + sugerido duplicado
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m28s
- Imagen principal del producto pasa de object-cover a object-contain
  (mismo motivo que las miniaturas del commit anterior): la foto
  vertical de Buzz Lightyear (933x1500) aparecía sin cabeza al
  seleccionarla, reportado en vivo con captura real.
- Descripción del producto baja de 16px a 14px (text-sm leading-6,
  mismo tamaño que los párrafos secundarios de /envios y /cambios) --
  seguía viéndose grande al lado del resto de la columna.
- El buy-box mostraba los mismos 2 productos que ya aparecían en el
  carrusel "También te puede gustar" de abajo (ambos usaban el mismo
  cálculo de categoría/colección). Ahora el buy-box sugiere 1 producto
  de una categoría DISTINTA -- cross-sell real en vez de duplicado.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 02:11:12 +00:00
devops 4009fde072 chore: release v1.0.259 [skip ci] 2026-09-14 01:53:53 +00:00
devopsandClaude Sonnet 5 0723b43c54 fix(ecommerce): corrige condición para ocultar el CTA fijo en mobile
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m10s
Bug real encontrado armando la prueba automatizada que valida el
commit anterior (cd72962): condicionar el ocultamiento a que el borde
superior de "También te puede gustar" cruce el TOPE del viewport
(top <= 0) no es alcanzable en páginas sin suficiente contenido/footer
debajo -- el scroll máximo del documento nunca llega a ese punto, y el
bug original (barra pegada hasta el fondo) seguía reproduciéndose en
la práctica, aunque el código "parecía" correcto.

Se compara ahora contra la ALTURA del viewport (top < innerHeight): la
barra se oculta apenas la sección empieza a asomar desde abajo, y
sigue oculta el resto del scroll sin depender de cuánto contenido haya
más abajo.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 01:45:25 +00:00
devops 58a1fd1597 chore: release v1.0.258 [skip ci] 2026-09-14 01:36:40 +00:00
devopsandClaude Sonnet 5 8889fd9ea2 fix(ecommerce): miniaturas recortaban fotos verticales + tamaño de fuente de descripción
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m11s
- Miniaturas pasan de object-cover a object-contain: fotos con
  relación de aspecto muy vertical (confirmado en vivo: 933x1500 en
  este catálogo) perdían cabeza o pies al forzar el recorte cuadrado.
  Reportado por el usuario viendo las miniaturas de un producto real.
- La descripción del producto bajaba de 18px (text-lg, mismo tamaño
  que un párrafo de apertura de página completa como /envios) a 16px
  (text-base) -- se veía desproporcionada al lado del resto de la
  columna de compra en el layout de 3 columnas.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 01:29:02 +00:00
devops 567ee30e1f chore: release v1.0.257 [skip ci] 2026-09-14 01:20:28 +00:00
devopsandClaude Sonnet 5 cd72962fa4 fix(ecommerce): CTA fijo de mobile ya no sigue hasta el footer
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m5s
La barra de "Añadir al carrito" quedaba pegada abajo en mobile hasta
el final de la página (incluso sobre el footer), mucho después de que
el usuario ya pasó "También te puede gustar". Se agrega un tercer
sentinel (#recommendations-section) observado por IntersectionObserver:
una vez que su borde superior cruza el tope del viewport, la barra se
oculta y no vuelve a aparecer bajando más.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 01:09:52 +00:00
devops af9576f45d chore: release v1.0.256 [skip ci] 2026-09-14 00:49:47 +00:00
devopsandClaude Sonnet 5 1c9693e573 fix(ecommerce): overflow horizontal en mobile hacía ver todo "a tamaño real"
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 30s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 19m41s
Root cause real del bug reportado por el usuario (imagen de producto
viéndose gigante/cortada en Chrome y Edge Android) -- no era la imagen:
al mover "También te puede gustar" DENTRO del grid de la página (para
el layout de 2 filas del commit anterior), su carrusel horizontal
(overflow-x-auto interno, sin flex-wrap) empezó a contar como item de
ese grid. Sin min-w-0, el ancho mínimo intrínseco de TODAS las cards
del carrusel sin wrappear se usa para dimensionar la columna
compartida del grid -- en mobile (grid de una sola columna), eso
empujaba TODO (imagen, título, specs) a ~800px de ancho en un viewport
real de ~380px. El navegador termina "zoomeando" el layout para que
entre, dando la sensación de que todo se ve a tamaño real / cortado.

Confirmado en vivo con bisección real: sin el fix, el grid medía 783px
en un viewport de 380px; agregando min-w-0 al contenedor del carrusel
vuelve a 380px. Se agrega min-w-0 también a los otros 3 contenedores
del grid (galería, info, buy-box) y a la tira de miniaturas dentro de
ProductGallery (mismo patrón de overflow-x-auto) como defensa
preventiva -- práctica estándar para items de grid/flex con scroll
horizontal interno.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 00:29:29 +00:00
devops f92e8e6b36 chore: release v1.0.255 [skip ci] 2026-09-14 00:09:53 +00:00
devopsandClaude Sonnet 5 ccaddf546c fix(ecommerce): imagen principal del producto se ve borrosa en Chrome/Edge Android
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m2s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m58s
Reportado en vivo por el usuario: la foto principal (y el modal de
zoom interactivo) se ven borrosas en mobile, especificamente en
Chrome y Edge de Android.

Investigado a fondo antes de tocar nada: no es un archivo corrupto ni
un problema de nuestro cache/backend -- confirmado con
fetch()+createImageBitmap() (que evita <img>/srcset por completo) que
el archivo real decodifica perfecto a resolución completa (1200x1200).
El bug está en cómo Chromium elige el candidato del srcset con
sizes="100vw" + fill a DPR alto (2.6+, cualquier Android real):
currentSrc reporta la URL de un candidato grande, pero
naturalWidth/Height terminan reflejando un candidato mucho más chico
-- reproducido igual en Chromium headless, y consistente con lo
reportado en Chrome y Edge Android reales (mismo motor).

No hay forma de arreglar el algoritmo de selección de Chromium desde
acá. La foto principal y el modal de zoom pasan a unoptimized: al
quedar un solo recurso real por foto (sin candidatos de por medio),
no hay entre qué elegir mal. Costo: la foto pesa el archivo completo
en vez de una versión redimensionada -- aceptable para dos imágenes
puntuales donde la nitidez importa.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 23:59:53 +00:00
devops 8d8546fd94 chore: release v1.0.254 [skip ci] 2026-09-13 23:09:47 +00:00
devopsandClaude Sonnet 5 57acdb0ed3 fix(ecommerce): "También te puede gustar" ya no espera a la columna más alta
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m53s
El buy-box (precio/carrito/sugeridos) es sticky y suele terminar
siendo la columna más alta del grid de producto -- al ser un grid de
una sola fila, el carrusel de recomendaciones de abajo (sección
aparte) solo empezaba una vez terminaba la columna MÁS alta,
dejando un hueco vacío enorme debajo de galería+descripción cuando
esas dos son más cortas que el buy-box.

Se reestructura a un grid de 2 filas desde xl: el buy-box ahora ocupa
ambas filas (row-span-2, sigue sticky), y "También te puede gustar"
pasa a ser la 2da fila pero solo debajo de galería+descripción
(col-span-2) -- corre en paralelo al buy-box en vez de esperarlo.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 22:59:33 +00:00
devops 5a2d0c061c chore: release v1.0.253 [skip ci] 2026-09-13 22:30:41 +00:00
devopsandClaude Sonnet 5 c3f3857a92 feat(ecommerce): aprovechar mejor el espacio en la página de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m36s
- "Detalles del producto" (marca, edad, categoría, colección) pasa de
  acordeón colapsado a una mini tabla siempre visible -- mismos datos
  reales de siempre, solo que ya no hace falta un click para verlos.
- Buy-box: nueva franja de confianza real con enlaces a /envios y
  /cambios (5 días hábiles, envío de vuelta gratis) -- mismas
  políticas ya publicadas en el footer, no texto inventado.
- "También te puede interesar" ahora muestra hasta 2 sugerencias en
  vez de 1, mismo cálculo real por categoría/colección que ya
  alimentaba el carrusel de abajo.

Investigado de paso (sin fix): la discrepancia de stock reportada
("Quedan 1" vs available:false) no es un bug de ProductPurchase -- la
página de producto usa /store/products de Medusa (en vivo, correcto),
mientras que /store/search (Meilisearch) devuelve inventario
desactualizado para el mismo producto. Eso vive en commerce-backend,
fuera de alcance de este cambio de frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 22:20:41 +00:00
devops 9b545dd850 chore: release v1.0.252 [skip ci] 2026-09-13 21:01:04 +00:00
devopsandClaude Sonnet 5 401f43a136 feat(ecommerce): página de producto estilo Amazon (buy-box + más ancho)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m38s
- max-w de la página de producto pasa de 1280px a 1600px -- aprovecha
  pantallas grandes en vez de dejar margenes vacíos enormes.
- Nueva 3ra columna desde xl: buy-box propio (tarjeta con borde,
  sticky) para precio/variantes/carrito, separado del texto
  descriptivo. Por debajo de xl cae al flujo normal de la 2da columna
  sin duplicar el componente (mismo <ProductPurchase>, un solo
  id="product-buy-button" en el DOM).
- Dentro del buy-box: un producto sugerido real de la misma
  categoría/colección (mismo cálculo que ya alimenta el carrusel de
  abajo, no un dato inventado).
- MobileAddToCart: la barra fija ahora también requiere haber
  scrolleado más de 80px además de que el botón real no esté en
  pantalla -- antes aparecía inmediatamente sobre la vista inicial del
  producto (imagen + variantes) apenas cargaba la página.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 20:53:01 +00:00
devops b23f19ac6b chore: release v1.0.251 [skip ci] 2026-09-13 20:42:39 +00:00
devopsandClaude Sonnet 5 9d4553f3a4 fix(ecommerce): panel de zoom dinámico y CTA móvil con revelado progresivo
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m33s
- El panel de zoom lateral ahora calcula tamaño/posición contra el
  viewport real (window.innerWidth/innerHeight) en cada mousemove, en
  vez de un breakpoint fijo (lg:block) con ancho fijo -- a resoluciones
  altas con poco espacio a la derecha de la imagen terminaba
  saliéndose de la pantalla. Si no entra un panel de al menos
  PANEL_MIN_SIZE, se cae automáticamente al overlay in-place (antes
  atado a "por debajo de lg", ahora a "no hay espacio real").
- La barra fija de "Añadir al carrito" en mobile quedaba fija desde el
  primer render, tapando permanentemente parte de la imagen del
  producto. Ahora el botón real (ProductPurchase) también se muestra
  en mobile, y la barra sticky solo aparece cuando ese botón sale del
  viewport (IntersectionObserver) -- mismo patrón que el "Buy Now"
  sticky de Amazon.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 20:34:43 +00:00
devops d605417452 chore: release v1.0.250 [skip ci] 2026-09-13 20:16:28 +00:00
devopsandClaude Sonnet 5 706732878f test(ecommerce): data-testid en lupa/panel/overlay de la galería
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m32s
Sin cambio de comportamiento -- solo hooks estables para la suite de
Playwright en .browser-scratch/checks.mjs (el selector genérico
div[aria-hidden] dejó de alcanzar para distinguir la lupa, el overlay
in-place y el panel lateral, que ahora coexisten en el DOM).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 20:07:36 +00:00
devops c222a6fed6 chore: release v1.0.249 [skip ci] 2026-09-13 20:02:18 +00:00
devopsandClaude Sonnet 5 8d16f2b854 fix(ecommerce): zoom de producto estilo lupa de Amazon (lupa + panel lateral)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m44s
El zoom in-place anterior (overlay de fondo dentro de la misma caja)
seguía sintiéndose confuso: el usuario esperaba el patrón clásico de
e-commerce (Amazon) -- un recuadro semitransparente ("lupa") que marca
qué región de la foto se está mirando, y un panel aparte, más grande,
que muestra esa región ampliada. Sin scroll ni superposición dentro de
la misma caja.

- Lupa: recuadro que sigue al cursor sobre la imagen principal,
  clampeado para no salirse de los bordes (visible en lg+).
  - bgX/bgY ahora se calculan por fracción de recorrido de la lupa
  (posición del borde de la lupa entre 0 y el ancho disponible), no por
  posición cruda del cursor -- mapeo correcto incluso cuando la lupa
  queda pegada a un borde.
- Panel: a la derecha de la imagen (position absolute, se superpone al
  contenido de al lado igual que en Amazon), mismo mecanismo de
  background-image con la foto completa que ya evitaba el problema de
  object-cover recortando partes de la imagen.
- Por debajo de lg (sin espacio para un panel aparte) se conserva el
  overlay in-place como fallback.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 19:51:10 +00:00
devops f0b1d7b745 chore: release v1.0.248 [skip ci] 2026-09-13 19:32:32 +00:00
devopsandClaude Sonnet 5 9ab779e781 fix(ecommerce): zoom-hover del producto revela la imagen completa
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m35s
El hover-zoom escalaba (transform:scale) la misma <Image> ya recortada
por object-cover -- para fotos verticales (ej. figura de acción de pie)
eso dejaba partes del producto (la cabeza) fuera del recorte desde el
propio render, así que ningún nivel de zoom las mostraba. Se reemplaza
por un overlay con background-image usando la foto completa
(background-size en un solo valor preserva el aspect ratio real) y
background-position siguiendo el cursor, que sí permite recorrer toda
la imagen.

De paso: las miniaturas ahora cambian la imagen principal también con
hover (onMouseEnter), no solo con click.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 19:21:39 +00:00
devops 2fa404fe7b chore: release v1.0.247 [skip ci] 2026-09-13 18:04:25 +00:00
gitea-actions 69cc254051 chore(gitops): deploy Medusa v1.0.246 [skip ci] 2026-09-13 17:55:04 +00:00
devopsandClaude Sonnet 5 76b7d827c5 fix(ecommerce): corrige origen de bodega Bogotá→Cali y activa redes/Cambios/FAQ
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 30s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m40s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m12s
- ENVIOCLICK_ORIGIN_DANE_CODE en configmap.yaml estaba cargado con el
  código DANE de Bogotá (11001000) por error; corregido a Santiago de
  Cali, Valle del Cauca (76001000). Toda cotización real de envío hecha
  antes de esta fecha se calculó desde el origen equivocado.
- Corregido también el fallback stale en medusa-config.ts
  (originDaneCode) que seguía apuntando a Bogotá.
- Actualizado el texto de /envios y /contacto (misma dirección física,
  Cra 100 #42-65, solo cambia la ciudad).
- Footer: Instagram y Facebook reales enlazados (verificados en vivo
  contra los perfiles antes de linkear), y "Preguntas frecuentes" /
  "Cambios" ya apuntan a contenido real en vez de /catalog.
- Nuevas páginas /cambios (5 días hábiles, envío de devolución a cargo
  de ARI Shopping) y /preguntas-frecuentes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 17:34:18 +00:00
devops 3efab94e2b chore: release v1.0.245 [skip ci] 2026-09-12 03:18:41 +00:00
devopsandClaude Sonnet 5 71c8752eeb Merge: páginas reales de Envíos y Contacto en el footer
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m24s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-12 02:55:28 +00:00
devopsandClaude Sonnet 5 cda25dea2e feat(ecommerce): páginas reales de Envíos y Contacto en el footer
El footer "Ayuda" no tenía contenido real -- los 4 links apuntaban a
/catalog. Se agregan dos páginas nuevas con información real:

- /envios: transportadoras reales de EnvíoClick (Coordinadora, Envía,
  TCC, Rappi, Pibox -- confirmadas en una cotización real, ver ADR
  0010 §2) y cómo se calcula el costo.
- /contacto: WhatsApp real (+57 301 613 8155, mismo número ya usado en
  el fallback de checkout) y correo [email protected] (reenviado
  por Cloudflare Email Routing -- configuración manual de DNS pendiente
  del lado del usuario, fuera de alcance de este cambio).

"Preguntas frecuentes" y "Cambios" quedan sin contenido real definido
todavía, apuntando a /catalog como antes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-12 02:55:24 +00:00
gitea-actions 392a832109 chore(gitops): deploy Docs Portal v1.0.244 [skip ci] 2026-09-11 13:36:00 +00:00
devops a069807960 chore: release v1.0.243 [skip ci] 2026-09-11 13:28:36 +00:00
devopsandClaude Sonnet 5 96b08eda01 Merge: resultados de la segunda tanda de pruebas con navegador real (ADR 0010)
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 6m43s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-11 13:12:21 +00:00
devopsandClaude Sonnet 5 e6c3e0ed8d docs(adr-0010): resultados de la segunda tanda de pruebas con navegador real
Checkout con carrito vacío, fallback WhatsApp, filtros de catálogo,
búsqueda sin resultados (bug encontrado y corregido en otro commit),
slug de producto inválido (hallazgo pendiente de investigar: responde
200 en vez de 404), zoom interactivo, wishlist, otros viewports,
página 404 genérica, accesibilidad básica del buscador, y registro/
login con email -- todo verificado con Chromium real vía el harness de
.browser-scratch/.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-11 13:12:00 +00:00
devopsandClaude Sonnet 5 35fdea5df5 Merge: mostrar mensaje cuando el buscador no tiene resultados
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 19m18s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-11 13:08:53 +00:00
devopsandClaude Sonnet 5 8d44f2e771 fix(ecommerce): mostrar mensaje cuando el buscador no tiene resultados
Encontrado probando en vivo con navegador real (Docker + Playwright):
una búsqueda de 2+ caracteres sin coincidencias no mostraba nada -- ni
"Buscando…" ni ningún mensaje. El usuario quedaba sin feedback de que
su búsqueda de verdad corrió. Se agrega un estado vacío con el botón
de buscar en todo el catálogo (esa búsqueda completa puede usar
criterios más amplios que el autocompletado).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-11 13:08:50 +00:00
gitea-actions ef6dc1f6a8 chore(gitops): deploy Docs Portal v1.0.242 [skip ci] 2026-09-10 21:46:38 +00:00
devopsandClaude Sonnet 5 352762b9d2 Merge: ampliar checklist de pruebas reales pendientes (ADR 0010)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m6s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 21:40:11 +00:00
devopsandClaude Sonnet 5 58cc93bb2f docs(adr-0010): ampliar checklist de pruebas reales pendientes
Cubre el resto de la superficie del storefront (checkout edge cases,
filtros de catalogo, variantes de producto, cuenta/wishlist,
accesibilidad, otros viewports) agrupado por riesgo, no solo lo ya
probado.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 21:40:09 +00:00
gitea-actions 5eaa94818f chore(gitops): deploy Docs Portal v1.0.241 [skip ci] 2026-09-10 17:56:48 +00:00
gitea-actions 66a81ad9a3 chore(gitops): deploy Medusa v1.0.239 [skip ci] 2026-09-10 17:49:42 +00:00
devops 39e1bf61d3 chore: release v1.0.240 [skip ci] 2026-09-10 17:23:09 +00:00
devopsandClaude Sonnet 5 b5a1f70d1d Merge: documentar resultados de pruebas con navegador real (ADR 0010)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 38s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m20s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 17:13:12 +00:00
devopsandClaude Sonnet 5 9d231322b8 docs(adr-0010): resultados de pruebas con navegador real + checklist ampliado
Cierra la mayor parte del checklist de pruebas reales que había quedado
pendiente: pago PSE hasta la redirección al banco, envío calculado
visible, banner y buscador mobile -- todo verificado con Chromium real
(Docker + Playwright en la NAS, ver .browser-scratch/README.md). Deja
documentados los 2 bugs reales que esa prueba encontró (ya corregidos
en otro commit) y un checklist ampliado para la próxima sesión.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 17:13:10 +00:00
devopsandClaude Sonnet 5 098ff990a6 Merge: filtrar producto sin stock del banner y banco placeholder de PSE
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 52s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m53s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 26m23s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 17:09:41 +00:00
devopsandClaude Sonnet 5 ff59618a2a fix: filtrar producto sin stock del banner y banco placeholder de PSE
Encontrado probando en vivo con navegador real (Docker + Playwright
contra shop.cruzcloud.net):

- app/page.tsx: un producto featured pero sin stock (available: false)
  salía primero en el hero banner con botón "Comprar ahora" hacia una
  página donde en realidad no se puede comprar.
- pse-banks/route.ts: el sandbox real de ePayco (bank.getBanks())
  incluye un primer elemento placeholder (bankCode "0", "A
  continuación seleccione su banco...") mezclado con los bancos reales,
  mostrado como opción seleccionable. Se filtra explícitamente.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 17:09:36 +00:00
devops 44e343eca7 chore: release v1.0.238 [skip ci] 2026-09-10 02:22:08 +00:00
devopsandClaude Sonnet 5 c77e958f04 Merge: fix Next.js CVE-2026-75604 (bump a 15.5.24)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 16m42s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 02:05:04 +00:00
devopsandClaude Sonnet 5 711bb2c76f fix(ecommerce): bump next 15.5.10 -> 15.5.24 (CVE-2026-75604, CRITICAL)
Trivy bloqueó el build del frontend en el pipeline de CI: Next.js
15.5.10 tiene un RCE no autenticado en la API de optimización de
imágenes vía archivos AVIF (CVE-2026-75604 / GHSA-2xp9-vwfh-vxw4),
corregido en 15.5.24. Bump mínimo dentro del mismo minor, sin otros
cambios de dependencias.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-10 02:04:55 +00:00
gitea-actions 08fc4174cb chore(gitops): deploy Docs Portal v1.0.237 [skip ci] 2026-09-09 21:59:34 +00:00
devopsandClaude Sonnet 5 89382f057c Merge: banner dinámico, buscador en dos filas y ADR 0010
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 7m11s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m19s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-09 21:45:37 +00:00
devopsandClaude Sonnet 5 1abc69825c feat(ecommerce): banner dinámico desde catálogo real y buscador en dos filas en mobile
- hero-carousel.tsx: slides derivados de productos featured/nuevos reales
  (imagen, nombre, precio, link, badge de % OFF) en vez de un array
  hardcodeado; fallback estático solo si la tienda no tiene productos
  activos.
- navbar.tsx: en mobile/tablet el buscador pasa a una segunda fila a
  todo el ancho debajo de logo+iconos (antes competía por espacio en una
  sola fila y quedaba recortado); desktop sin cambios.
- docs: ADR 0010 documentando el pivote de pago PayU→Rapyd(descartado)→
  ePayco, la integración de EnvíoClick y checklist de pruebas reales
  pendientes desde el storefront; puntero agregado desde ADR 0009.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-09 21:45:25 +00:00
devops 1e6009daf7 Merge chore/envioclick-datos-reales-negocio into main 2026-09-09 03:35:59 +00:00
devopsandClaude Sonnet 5 7a95957945 chore(envioclick): actualizar datos reales de contacto/dirección de origen
Placeholders reemplazados por los datos reales que dio el usuario --
dirección de la bodega (Cra 100 #42-65), email y teléfono de contacto.
Estos valores aparecen como remitente en las guías reales de EnvíoClick.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-09 03:35:51 +00:00
gitea-actions a973b2671e chore(gitops): deploy Medusa v1.0.234 [skip ci] 2026-09-08 21:52:18 +00:00
devops bdd7c06a32 chore: release v1.0.235 [skip ci] 2026-09-08 21:25:08 +00:00
devops 0c356d6fbe Merge feat/envioclick-fulfillment into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m5s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 27m11s
2026-09-08 21:11:54 +00:00
gitea-actions e42d588514 chore(gitops): deploy Medusa v1.0.233 [skip ci] 2026-09-07 04:47:15 +00:00
devops e2938248f8 chore: release v1.0.232 [skip ci] 2026-09-07 04:23:21 +00:00
36 changed files with 1823 additions and 188 deletions

No files matched your search

+14 -2
View File
@@ -126,8 +126,13 @@ jobs:
env:
APP_DIR: workloads/ecommerce
MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce/frontend
# 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.
MANIFEST_FILE: workloads/ecommerce-staging/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce-staging/frontend
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
steps:
@@ -579,6 +584,13 @@ jobs:
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}"
# El footer (workloads/ecommerce/components/layout/footer.tsx) lee
# process.env.APP_VERSION para mostrar la versión real desplegada
# -- este bump la mantiene igual al tag de la imagen de arriba.
# -0777 (slurp): el nombre y el valor están en líneas distintas del
# YAML, un sed de una sola línea no puede verlos juntos.
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${VERSION}/" "${MANIFEST_FILE}"
git add "${MANIFEST_FILE}"
if git diff --cached --quiet; then
+26
View File
@@ -0,0 +1,26 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ecommerce-staging-app
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.cruzcloud.net/devops/apps-registry.git
targetRevision: main
path: workloads/ecommerce-staging
kustomize: {}
destination:
server: https://kubernetes.default.svc
namespace: ecommerce-staging
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ecommerce-staging-governance
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.cruzcloud.net/devops/platform-infra.git
path: ecommerce-staging-governance
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: ecommerce-staging
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+1 -1
View File
@@ -160,7 +160,7 @@ module.exports = defineConfig({
options: {
apiKey: required("ENVIOCLICK_API_KEY"),
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "11001000",
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "76001000",
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
@@ -38,11 +38,22 @@ import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// generaba un producto duplicado con handle distinto (confirmado en
// vivo con "Hello Kitty & Friends": misma fila re-subida para sumar
// fotos, dos productos con el mismo titulo en la tienda). El match es
// por titulo exacto (case-insensitive/trim), igual que categoria y
// coleccion; en un match, las fotos nuevas se agregan a las que ya
// tenia (no se reemplazan) y la descripcion se actualiza si la fila
// trae una. No toca precio/cantidad/categoria/coleccion del producto
// existente -- eso se edita a mano en el admin si hace falta.
// por titulo normalizado (case/espacios/guiones-comillas tipograficas,
// ver normalizeTitleForMatch), igual que categoria y coleccion; en un
// match, las fotos nuevas se agregan a las que ya tenia (no se
// reemplazan) y la descripcion se actualiza si la fila trae una. No
// toca precio/cantidad/categoria/coleccion del producto existente --
// eso se edita a mano en el admin si hace falta.
//
// El match era por titulo exacto (case-insensitive/trim) hasta que
// volvio a duplicar "Toy Story 5 - Woody": la fila reimportada traia
// un guion largo "–" (pegado desde Excel/Sheets, que autocorrige "-" a
// en-dash) en vez del guion normal "-" del producto original, asi que
// el string no calzaba y se creo un producto nuevo en vez de
// actualizar el existente (confirmado en vivo 2026-09-19). Ademas de
// guiones, se normalizan comillas tipograficas por la misma razon --
// cualquier CSV pegado desde un editor de texto enriquecido puede
// traerlas.
//
// Las fotos se descargan del origen y se re-alojan en el mismo
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
@@ -88,6 +99,22 @@ type RowResult = {
// mismos, ASCII-only, en vez de dejarselo al default de Medusa. El sufijo
// corto evita colisiones entre titulos parecidos/repetidos sin necesidad
// de revisar unicidad antes de crear.
// Colapsa variantes de guion (-, en-dash, em-dash, minus) y comillas
// tipograficas a su forma ASCII antes de comparar titulos, para que un
// re-import pegado desde Excel/Sheets/Word (que autocorrigen esos
// caracteres) siga matcheando el producto ya existente. Ver nota mas
// arriba sobre "Toy Story 5 - Woody".
function normalizeTitleForMatch(title: string): string {
return title
.normalize("NFKC")
.replace(/[‐-―−]/g, "-")
.replace(/[‘’‛]/g, "'")
.replace(/[“”‟]/g, '"')
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function slugify(title: string, uniquePart: string): string {
const base = title
.normalize("NFD")
@@ -190,13 +217,13 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}
const categoryByName = new Map(
categories.filter((c) => c.name).map((c) => [c.name.toLowerCase().trim(), c.id]),
categories.filter((c) => c.name).map((c) => [normalizeTitleForMatch(c.name), c.id]),
);
const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]),
collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(c.title), c.id]),
);
const productByTitle = new Map(
existingProducts.filter((p) => p.title).map((p) => [p.title.toLowerCase().trim(), p]),
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
);
const results: RowResult[] = [];
@@ -228,7 +255,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
},
});
const existingProduct = productByTitle.get(title.toLowerCase());
const existingProduct = productByTitle.get(normalizeTitleForMatch(title));
if (existingProduct) {
await updateProductsWorkflow(req.scope).run({
input: {
@@ -262,7 +289,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryName = row.categoria?.trim();
let categoryId = categoryName ? categoryByName.get(categoryName.toLowerCase()) : undefined;
let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined;
if (categoryName && !categoryId) {
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
input: {
@@ -272,11 +299,11 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
},
});
categoryId = createdCategories[0].id;
categoryByName.set(categoryName.toLowerCase(), categoryId);
categoryByName.set(normalizeTitleForMatch(categoryName), categoryId);
}
const collectionName = row.coleccion?.trim();
let collectionId = collectionName ? collectionByName.get(collectionName.toLowerCase()) : undefined;
let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined;
if (collectionName && !collectionId) {
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
input: {
@@ -286,7 +313,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
},
});
collectionId = createdCollections[0].id;
collectionByName.set(collectionName.toLowerCase(), collectionId);
collectionByName.set(normalizeTitleForMatch(collectionName), collectionId);
}
await createProductsWorkflow(req.scope).run({
@@ -12,6 +12,12 @@ import { createEpaycoClient, type EpaycoEnv } from "../../../../lib/epayco";
// contra el sandbox real en esta sesion -- se prueban los nombres de
// campo mas probables (bankCode/bankName y variantes) y se degrada a
// lista vacia si no matchea nada, en vez de romper el checkout entero.
//
// Verificado en vivo despues (navegador real, Playwright): el sandbox
// real de ePayco incluye un primer elemento placeholder
// (bankCode "0", "A continuación seleccione su banco...") mezclado con
// los bancos reales -- ningun banco PSE real tiene codigo "0", asi que
// se filtra explicitamente en vez de mostrarlo como opcion seleccionable.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
let cache: { banks: Array<{ pseCode: string; description: string }>; fetchedAt: number } | null = null;
@@ -42,7 +48,7 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
? { pseCode: String(pseCode), description: String(description ?? pseCode) }
: null;
})
.filter((bank): bank is { pseCode: string; description: string } => bank !== null);
.filter((bank): bank is { pseCode: string; description: string } => bank !== null && bank.pseCode !== "0");
cache = { banks, fetchedAt: Date.now() };
res.json({ banks });
@@ -81,4 +81,70 @@
hacía falta agregar una clave nueva) — un script de setup inicial y
uno de "agregar una clave a un secret ya vivo" deberían haber sido
dos herramientas distintas desde el principio, no una sola que hay
que saber cuándo NO correr completa.
que saber cuándo NO correr completa.
## CSS Grid: un scroller horizontal sin `min-w-0` puede desbordar todo el grid
Encontrado en producción (2026-09-13/14) en la página de producto del
storefront: un síntoma que parecía un bug de imágenes ("se ve como si
abriera en tamaño real" en Chrome/Edge Android) resultó ser, en
realidad, un desborde de layout. El carrusel horizontal de "También te
puede gustar" (`overflow-x-auto`, sin `flex-wrap`) vivía como hijo
directo de un CSS Grid. Por spec, el tamaño mínimo automático de un
item de grid es el tamaño mínimo de su contenido **a menos que el
propio item** tenga `overflow` distinto de `visible` — y como el `div`
que envolvía al carrusel no lo tenía, el ancho mínimo intrínseco de
*todas* las tarjetas del carrusel sin wrappear se contaba como el
mínimo de esa columna, forzando **todo el grid compartido** (imagen,
título, specs) a ~800px de ancho en un viewport real de ~380px.
**Lección reusable:** cualquier fila con scroll horizontal (carrusel,
tira de miniaturas, chips de filtro) que sea hijo directo de un CSS
Grid necesita `min-w-0` en su wrapper (o `overflow` seteado
directamente en el item de grid) — si no, su contenido sin wrappear
puede reventar el ancho de todo el grid en silencio, y el síntoma
visible no tiene nada que ver con imágenes ni con DPR. Diagnosticado
en vivo comparando `window.innerWidth` (reportaba el ancho
desbordado) contra el viewport real del dispositivo.
## Rediseño de página de producto y correcciones de datos reales (2026-09-13/14)
Sesión de correcciones guiadas por capturas reales del usuario en
navegadores reales (no reportes abstractos):
- **Bodega mal configurada:** `ENVIOCLICK_ORIGIN_DANE_CODE` apuntaba a
Bogotá (`11001000`) cuando la bodega real está en Santiago de Cali,
Valle del Cauca (`76001000`) — afectaba el cálculo real de envío
(EnvíoClick) en cada checkout. Corregido en el ConfigMap del
frontend, en `commerce-backend/medusa-config.ts` (incluyendo un
fallback obsoleto que también apuntaba a Bogotá) y en el registro
real de la bodega en Medusa (vía admin temporal, mismo patrón que el
resto del lab para ediciones puntuales en vivo).
- **Redes sociales y páginas de ayuda reales:** el footer enlazaba
cuentas placeholder; se verificó en vivo la cuenta real de Instagram
antes de linkearla (la primera sugerida no era la correcta) y se
activaron los enlaces reales de Instagram/Facebook/WhatsApp, además
de las páginas `/cambios` y `/preguntas-frecuentes` con contenido
real (ya existían en código, no estaban enlazadas).
- **Zoom estilo Amazon en la galería de producto:** iteración en tres
pasos hasta llegar a lo pedido — `transform: scale()` sobre una
imagen ya recortada por `object-cover` (no funcionaba: lo recortado
nunca estaba en el DOM), superposición in-place (técnicamente
correcta pero no la UX pedida), y finalmente una lupa que sigue el
cursor más un panel lateral que magnifica esa región — el patrón
real de Amazon. El tamaño/posición del panel se calcula en JS contra
`window.innerWidth/innerHeight` en cada `mousemove` (no un
breakpoint CSS fijo), con fallback a la superposición in-place si no
hay espacio.
- **Barra flotante de "Añadir al carrito" en mobile: removida, no
reajustada.** Pasó por varias iteraciones de lógica de
mostrar/ocultar (con `IntersectionObserver`) que seguían
superponiéndose a otro contenido en capturas reales del usuario. En
vez de seguir ajustando el umbral, se eliminó el componente por
completo y se dejó el botón inline normal, visible también en
mobile — el mismo patrón que usa la mayoría de tiendas. Verificado
con Playwright: cero elementos `position: fixed` en la página de
producto.
- **Ambiente de Staging + versión real en el footer:** ver
[ADR 0011](../decisiones/0011-ambiente-staging.md) para el detalle
completo.
@@ -230,9 +230,15 @@ falla:
contenedor.
- **Existencia de la sesión tmux `lab`** (`tmux has-session`): si no
existe, **sí se recrea automáticamente** (`tmux new-session -d -s lab
-c <WORKDIR>`) — es solo recuperar el propio entorno de trabajo del
contenedor, no tocar infraestructura externa, así que es seguro
hacerlo sin intervención humana.
-c <WORKDIR>`, seguido de `tmux send-keys -t lab claude --remote-control
--continue Enter` — ver gotcha "sesión recreada pero sin Claude Code
adentro" más abajo, y los gotchas de Remote Control y `--continue`
agregados el 2026-09-20) — es solo recuperar el propio entorno de
trabajo del contenedor, no tocar infraestructura externa, así que es
seguro hacerlo sin intervención humana. Si `WORKDIR` todavía no está
montado (ver gotcha siguiente),
no se crea la sesión esa vuelta — se reintenta en el próximo ciclo en
vez de crearla mal posicionada.
Todo evento (chequeo ok, falla de red, sesión recreada) queda en
`/DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log` — persistente en
@@ -247,14 +253,161 @@ contenedor con `-e HEALTHCHECK_INTERVAL_SEC=60` (solo para pruebas en un
contenedor descartable — nunca cambiar esto en el `tmux-host` real sin
volver a 1800 después).
## Gotcha: sesión recreada, pero sin Claude Code adentro (reinicio completo del NAS, 2026-09-19)
Distinto al caveat de persistencia de sesión de más abajo (ese cubre "el
*proceso* muere pero el contenedor sigue vivo" — cerrar la sesión `lab` a
mano, o un crash del propio `claude`). Este es otro escenario: **el HOST
completo se apaga y vuelve a arrancar desde cero** (corte de luz, no un
`docker restart` ordenado) — reordena cuándo arrancan Docker, la red y los
montajes de `/DATA`/`/media/HDD-Storage` entre sí, algo que un
`docker restart` normal nunca ejercita porque el host ya estaba arriba.
Tras un corte de luz real, se encontró que el usuario se reconectó a la
sesión `lab` y encontró un shell vacío, no Claude Code corriendo. Dos
causas distintas, ambas corregidas:
1. **La sesión tmux SÍ se recreaba sola** (confirmado en `health.log`: el
contenedor arrancó y la sesión `lab` quedó recreada en cuestión de
segundos), pero `check_tmux_session()` nunca lanzó `claude` adentro —
solo garantizaba que existiera el *shell*, no que el proceso de Claude
Code estuviera corriendo. El diagrama de arquitectura de este mismo doc
siempre mostró `T --> CC["proceso claude"]` como si fuera automático,
pero el código nunca lo implementó — el hueco quedó invisible mientras
nadie pasó por un reinicio completo real del NAS (en un `docker
restart` normal, el `claude` que ya estaba corriendo dentro de la
sesión simplemente sigue vivo, porque ahí el proceso *no* muere).
**Fix:** `check_tmux_session()` ahora llama `launch_claude_in_session()`
(`tmux send-keys -t lab claude Enter`) justo después de crear la sesión
— solo cuando la crea de cero, nunca sobre una sesión que ya existe (no
se reinyecta `claude` sobre un pane con trabajo en curso).
2. **Riesgo de carrera no ejercitado todavía, pero real:** `WORKDIR`
(`/DATA/AppData/k3d-lab-v3/lab-cruzcloud`) llega vía el symlink absoluto
`/DATA/AppData -> /media/HDD-Storage/AppData` (ver el gotcha del symlink
más arriba). Si el storage externo (`/media/HDD-Storage`) tarda en
montarse en el host más de lo que tarda Docker en arrancar este
contenedor — plausible después de un corte de luz, no de un simple
restart —, `mkdir -p "$LOG_DIR"` fallaba en silencio (`set -u` sin
`-e`) y ese arranque quedaba *sin ningún rastro* en el log, y
`tmux new-session -c "$WORKDIR"` habría creado la sesión mal
posicionada (o fallado) contra un directorio que todavía no existe.
**Fix:** `wait_for_workdir()` reintenta con backoff (pasos de 5s, hasta
`STARTUP_MOUNT_WAIT_MAX_SEC=120` por defecto) a que `WORKDIR` exista
antes de tocar el log o crear la sesión; si sigue sin existir pasado
ese máximo, se registra el error explícitamente y se reintenta en el
siguiente ciclo (30 min) en vez de fallar una sola vez y quedarse así.
**Validado en vivo:** imagen reconstruida (`tmux-host:test-fix`) y probada
en un contenedor descartable aparte (mismos mounts de `/DATA` y
`/media/HDD-Storage`, sin tocar el `tmux-host` real) — `docker stop` +
`docker start` (arranque desde cero, no `restart`) recreó la sesión `lab`
con `claude` corriendo adentro sin intervención manual, en ~1 segundo.
## Gotcha: Remote Control nunca se activaba solo (2026-09-20)
Con la sesión `lab` recreándose sola y `claude` corriendo adentro (gotcha
anterior), quedaba un problema distinto y más sutil: la sesión servía
perfecto para trabajar vía `docker exec`/`lab-session.sh`, pero nunca
aparecía como controlable remotamente en claude.ai — como si Remote
Control jamás se hubiera activado.
**Causa raíz (confirmado en vivo 2026-09-20):** Remote Control es
opt-in por lanzamiento, vía el flag `claude --remote-control [nombre]`
de la CLI. Nada en `launch_claude_in_session()` lo pasaba nunca, así
que `remoteEnabled` en `~/.claude.json` quedaba en `false` para siempre
porque nadie lo pedía. No existe un paso separado de confirmación
"¿Activar Remote Control? y/n" más allá del diálogo normal de
confianza del folder (una hipótesis inicial de que sí existía tal paso,
y de que había una clave `remoteControlAtStartup` en `settings.json`,
resultó falsa al revisar directamente los archivos de configuración
reales).
**Cómo se detectó:** lanzar `claude --remote-control` a mano (en una
ventana de tmux en foreground, no vía un `!` de un solo uso que se
mata al terminar) sí registró la sesión y apareció en la lista de
sesiones activas de claude.ai como `zimaos-quizzical` — el hostname del
contenedor (`ZimaOS`) se usa como prefijo por defecto del nombre de
sesión remota.
**Fix:** cambiar la línea `tmux send-keys` en
`launch_claude_in_session()` para lanzar `claude --remote-control` en
vez de `claude` a secas.
**Validado en vivo:** tras reconstruir la imagen y swapear el
contenedor real (ver el gotcha del swap más abajo), `health.log`
registró `'claude --remote-control' lanzado...` al recrear la sesión, y
el pane de tmux mostró `/remote-control is active` con un link de
sesión válido — sin que nadie lo activara a mano.
## Gotcha: cada recreación de la sesión empezaba una conversación en blanco (2026-09-20)
Con Remote Control ya activándose solo, quedaba una fricción menor pero
molesta: cada vez que la sesión `lab` se recreaba (reinicio del NAS,
`docker rm` + recrear, o el propio healthcheck reaccionando a un
crash), `launch_claude_in_session()` arrancaba una conversación
completamente nueva. El contexto de trabajo en curso —no las
credenciales ni la confianza del folder, que sí persisten vía volumen—
se perdía cada vez, sin relación con los límites de cuota (ver la nota
al principio de este doc).
**Fix:** agregar `-c`/`--continue` (retoma la conversación más reciente
en el directorio de trabajo actual) al comando de lanzamiento:
`claude --remote-control --continue`. Si no hay ninguna conversación
previa en `WORKDIR`, el propio CLI arranca una nueva sin error — no
hace falta lógica condicional en el script.
**Validado en vivo — la prueba más fuerte posible:** el swap que
desplegó este mismo cambio (ver gotcha siguiente) mató la sesión de
Claude Code que lo estaba ejecutando. Del otro lado, la *misma
conversación* siguió sin cortarse, con el mismo ID de sesión, sin
ninguna intervención manual — evidencia directa de que `--continue`
efectivamente retoma la conversación y no solo que el contenedor volvió
a levantar.
## Gotcha: actualizar la imagen de `tmux-host` en caliente mata al propio proceso que hace el swap (2026-09-20)
Este contenedor tiene un problema estructural para actualizarse a sí
mismo: el proceso de Claude Code que ejecutaría `docker stop tmux-host
&& docker rm tmux-host && docker run ... tmux-host:nueva-imagen` corre
*dentro* del propio contenedor `tmux-host` — el primer `docker stop`
mata su propia shell a mitad de la secuencia, dejando el swap a medio
terminar (visto en vivo: un primer intento así falló en silencio).
**Fix:** correr la secuencia de swap desde un contenedor *sibling*
efímero, no desde dentro de `tmux-host`:
```bash
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /DATA/AppData/k3d-lab-v3/tmux-host:/tmux-host-cfg \
docker:cli sh /tmux-host-cfg/swap-retry.sh
```
Como este helper solo comparte el socket de Docker (no vive *dentro*
del contenedor `tmux-host`), detener y recrear `tmux-host` no lo afecta
a él ni al proceso que lo lanzó desde el host. El script
(`swap-retry.sh`, junto a sus logs, vive en
`/DATA/AppData/k3d-lab-v3/tmux-host/` — mismo path físico que
`/media/HDD-Storage/AppData/k3d-lab-v3/tmux-host/`, ver el gotcha del
symlink más arriba) deja un log fechado por corrida en `logs/` con el
`docker ps` y un `grep` sobre `healthcheck.sh` dentro de la imagen
nueva, para verificar sin tener que adivinar si el fix realmente quedó
adentro.
**Validado en vivo dos veces (2026-09-20):** el fix de Remote Control y
el de `--continue` se desplegaron cada uno con este mismo mecanismo,
ambos confirmados por `docker ps` (imagen e `CreatedAt` nuevos) y por
`health.log` mostrando la línea de lanzamiento esperada inmediatamente
después del swap.
## Qué sobrevive a qué
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? |
|---|---|---|
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí |
| `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` |
| `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`) |
| 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) |
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? | ¿Retoma la misma conversación? |
|---|---|---|---|
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí | Sí — el proceso `claude` ni se entera |
| `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 |
## Validación en vivo
@@ -1,7 +1,10 @@
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
**Estado:** aceptada — proveedor de pago decidido (PayU, §3);
implementación real (credenciales, shipping options, código) pendiente
**Estado:** aceptada — proveedor de pago decidido (PayU, §3); la
implementación real terminó usando ePayco en vez de PayU (PayU LatAm
quedó inviable, Rapyd se evaluó y se descartó sin código) y sumó
EnvíoClick para envío calculado — ver [ADR 0010](./0010-pago-envio-reales-y-storefront.md)
para el pivote completo y el estado final desplegado
**Fecha:** 2026-08-28
## Contexto
@@ -0,0 +1,427 @@
# ADR 0010 — Pago (ePayco PSE) y envío (EnvíoClick) reales; banner y buscador del storefront
**Estado:** aceptada — ambas integraciones desplegadas y verificadas en vivo (sandbox de cada proveedor); banner dinámico y buscador rediseñado desplegados
**Fecha:** 2026-09-09
## Contexto
El ADR 0009 dejó **PayU** decidido como proveedor de pago de negocio y
documentó la necesidad de un módulo custom (`AbstractPaymentProvider`)
porque ningún plugin comunitario de Medusa sirve para la API real de
PayU LatAm. Entre esa decisión y la implementación real pasaron dos
correcciones más, ninguna prevista en el ADR original — este documento
cierra esa cadena y agrega la segunda mitad de "checkout real" que el
0009 dejó pendiente: el envío (EnvíoClick), más dos mejoras de UI del
storefront relacionadas (banner dinámico, buscador) pedidas una vez que
ambas integraciones ya estaban en producción.
## 1. Pago — de PayU a Rapyd a ePayco
### 1.1 PayU LatAm quedó inviable: Rapyd compró ese negocio
Al intentar registrar la cuenta de comercio en `developers.payulatam.com`
(2026-08-29), el registro real redirige a **Rapyd** — confirmado en vivo
contra `board.rapyd.net` con capturas de pantalla del propio usuario, no
solo documentación. Investigación confirmó la causa: **Rapyd adquirió el
negocio de PayU LatAm/África en 2023.** PayU LatAm como entidad separada
con la API documentada en el ADR 0009 (`apiLogin`/`apiKey`/`accountId`)
ya no es la vía de registro real para un comercio nuevo.
Se investigó y planificó por completo la alternativa Rapyd (API REST
moderna, firma HMAC-SHA256, endpoint `/v1/payments` — arquitectura
totalmente distinta a la de PayU LatAm clásico, no una migración menor
de credenciales) pero **nunca se escribió código**: antes de implementar,
el usuario planteó ePayco como alternativa y, al evaluarla, resultó
preferible por costo/encaje — Rapyd quedó descartado en la etapa de plan,
no de código.
### 1.2 Decisión final: ePayco
**Motivo de negocio:** ePayco es 100% propiedad de Davivienda desde 2024
— mejor tarifa PSE dado que Davivienda ya es uno de los bancos
disponibles del comercio (mismo dato de negocio del ADR 0009 §3:
Davivienda, Lulo Bank, Banco de Bogotá).
**Hallazgo técnico no obvio, verificado en vivo, no en documentación:**
ePayco tiene dos productos de integración distintos bajo la misma marca:
- Un producto nuevo tipo "Apify"/Web-Checkout (widget alojado).
- La integración real de PSE que efectivamente se usó: la API
"restpagos" / `secure.payco.co`, **más antigua**, que combina login con
bearer token **más cifrado AES-CBC campo por campo** usando la llave
privada del comercio como clave AES con IV en cero — un protocolo poco
común que no vale la pena reimplementar a mano. Se optó por depender
del paquete oficial `epayco-sdk-node` en vez de escribir el cliente
HTTP propio (a diferencia de EnvíoClick, ver §2).
Nombres de método/campo reales del SDK, corregidos contra su código
fuente instalado (no contra su propia documentación pública, que estaba
desactualizada respecto al paquete):
- Lista de bancos PSE: `getBanks()` (la doc pública sugiere `pseBank()`,
que no existe en el SDK real).
- Confirmación de método: campo `method_confirmation` (la doc pública
sugiere `metodoconfirmacion`).
**Implementación** (`commerce-backend/src/modules/epayco-pse/`,
`src/lib/epayco.ts`): módulo de pago custom
(`AbstractPaymentProvider`), estado `pending_authorization` tras iniciar
el pago (mecanismo documentado de Medusa para métodos asíncronos —
permite que el carrito complete en una orden "esperando confirmación" sin
bloquear sobre la respuesta del banco), webhook nativo de Medusa en
`/hooks/payment/epayco_pse` (sin subscriber custom — el
`payment-webhook` + `process-payment-workflow` incluidos en el core ya
completan la orden automáticamente al recibir la notificación).
**Verificado en vivo (2026-09):**
- Credenciales reales del comercio (`P_CUST_ID_CLIENTE`, `P_KEY`,
`PUBLIC_KEY`, `PRIVATE_KEY`) cargadas en el secret real del cluster.
- Llamada real a `getBanks()` contra la API de ePayco devolvió el listado
real de bancos PSE en modo sandbox.
- El webhook `/hooks/payment/epayco_pse` es alcanzable desde afuera:
`POST` externo contra `https://commerce.cruzcloud.net/hooks/payment/epayco_pse`
responde `200 OK` (la firma inválida se maneja puertas adentro sin
romper la respuesta HTTP) — cierra el riesgo de red que el ADR 0009 §2
había señalado como pendiente de validar (mismo patrón de incidente ya
documentado para otro servicio,
`incidente-cloudflared-504-quic-2026-08.md`).
**Limitaciones conocidas y aceptadas, no bloqueantes:**
- `refundPayment` tira un error explícito de "no implementado" — el
endpoint real de reembolso nunca se verificó en vivo, se prefirió
fallar ruidosamente a fingir éxito.
- El nombre exacto del campo de la URL de redirección del banco en la
respuesta de `bank.create()` se maneja de forma defensiva, probando
varios nombres candidatos con un error explícito y el payload completo
si ninguno matchea — **todavía no se probó con un pago PSE real de
principio a fin** (solo `getBanks()` está confirmado en vivo). Ver §3
para cómo cerrar esto desde el storefront real.
- El comercio está en modo **sandbox** de ePayco (`EPAYCO_ENV: sandbox`
en el ConfigMap) — no se ha movido dinero real todavía.
## 2. Envío — EnvíoClick reemplaza la tarifa fija
El ADR 0009 (nota de implementación) dejó una shipping option con tarifa
fija de $15.000 COP como **placeholder de negocio**, anotando
explícitamente que un cálculo real por peso/volumen era trabajo futuro
— "eso es justamente lo que EnvíoClick resolvería más adelante". Este
ADR cierra ese punto.
### Hallazgo de negocio, no técnico, que condiciona la precisión real
**Ningún producto del catálogo tiene peso ni dimensiones cargados**
(confirmado contra la base real: 18 productos activos, 0 con `weight`,
0 con `length`). Sin este dato, cualquier cotización real de EnvíoClick
no puede ser precisa. Se aceptó como **tarea de datos pendiente, no de
código** — la integración usa un paquete de fallback conservador (1 kg,
20×20×20 cm) mientras no haya datos reales por producto, documentado en
`src/modules/envioclick-fulfillment/types.ts` (`DEFAULT_PACKAGE`).
### API real, corregida contra la fuente primaria, no contra un plugin de referencia desactualizado
Un plugin de WordPress usado como primera referencia resultó ser para la
**API v1** de EnvíoClick, ya obsoleta. La doc real
(`apidoc.envioclickpro.com.co`, generada con apiDoc — se bajó y leyó
directo `api_data.json`/`api_project.json`, ya que la SPA no renderizaba
sin sesión) reveló diferencias reales, confirmadas con una llamada real
exitosa:
| | Plugin de referencia (v1, desactualizado) | API real (v2, confirmada en vivo) |
|---|---|---|
| Base URL | `api.envioclickpro.com` | `https://api.envioclickpro.com.co/api/v2` |
| Header de auth | `AuthorizationKey: <key>` | `Authorization: <key>` (sin prefijo) |
`POST /quotation` devolvió en vivo tarifas reales de PIBOX, COORDINADORA,
ENVIA, RAPPI y TCC para una cotización de prueba real — se toma
`flete + minimumInsurance` (seguro mínimo obligatorio, hay que sumarlo al
flete para el precio real a cobrar) más barato del array `data.rates[]`,
mismo criterio de "más económico" que ya usaba el plugin de referencia.
### Hallazgo crítico de Medusa, evitado antes de romper nada (no un incidente real)
Leyendo directo `@medusajs/utils/dist/common/define-config.js` (no
documentación): `defineConfig()` ya registra por defecto
`Modules.FULFILLMENT` con el provider `manual_manual`, aunque
`medusa-config.ts` no lo mencione explícitamente. `transformModules()`
indexa por `serviceName` y el último bloque que se declara **pisa**, no
suma, al anterior. Agregar un bloque de fulfillment propio sin incluir
también `@medusajs/medusa/fulfillment-manual` adentro habría roto el
fulfillment manual existente en silencio — mismo tipo de riesgo que casi
pasó con `payment` en el ADR 0009, evitado leyendo el código fuente real
antes de escribir el bloque nuevo (ambos providers conviven hoy en el
mismo bloque de `medusa-config.ts`).
**Implementación** (`commerce-backend/src/modules/envioclick-fulfillment/`,
`src/lib/envioclick.ts`): `AbstractFulfillmentProviderService`,
`calculatePrice()` invocado en vivo cada vez que el carrito refresca
shipping options (Medusa advierte que si este método falla, **bloquea el
checkout** — el fallback conservador de arriba existe también por esto,
no solo por falta de datos). `createFulfillment()` genera la guía real vía
`/shipment` (o `/shipment_sandbox` en modo prueba —
**el único endpoint con sandbox real**, la cotización siempre usa
tarifas reales). Sin cambios en el storefront: `GET /store/shipping-options`
nativo de Medusa ya invocaba `calculatePrice()` automáticamente para
cualquier shipping option `price_type: "calculated"`.
**Verificado en vivo (2026-09):**
- Shipping option "Envío nacional estándar" migrada de `manual_manual`/
tarifa fija a `envioclick_standard`/`calculated` vía Admin API (con
usuario admin temporal, eliminado después de usarlo).
- Un `400` inicial (`"Providers (envioclick_standard) are not enabled
for the service location"`) se resolvió vinculando el provider a la
stock location (`POST /admin/stock-locations/:id/fulfillment-providers`)
— Medusa exige esto además del bloque de módulo.
- Carrito de prueba real con dirección → `shipping_total: 8941` COP,
tarifa real de EnvíoClick, no el fijo de $15.000.
- Datos reales de la bodega de origen cargados (dirección `Cra 100
#42-65`, contacto `cristiancruz29@outlook.com` / `3014750823`) tanto en
el ConfigMap (`ENVIOCLICK_ORIGIN_*`) como en el registro real de la
stock location en Medusa (antes: "Sin dirección física registrada").
- El comercio está en modo **sandbox** de EnvíoClick
(`ENVIOCLICK_SANDBOX: "true"`) — las guías que se generan son de
prueba, sin costo; ninguna cotización es de prueba (no existe sandbox
de cotización).
**Limitaciones conocidas y aceptadas, no bloqueantes:**
- `createReturnFulfillment` tira "no implementado" — devoluciones fuera
de alcance.
- Cancelación de guía real (`POST cancellation/batch/order`, hasta 50 por
lote, usando los `idOrder` que devuelve `/shipment`) confirmada como
existente en la API pero **no implementada en el módulo** — se
descubrió durante la investigación, no se integró por no ser parte del
flujo de checkout mínimo.
- Solo EnvíoClick como provider — no hay selección de múltiples
couriers por el comprador (v1 siempre toma el más barato).
- Ningún producto tiene peso/dimensiones reales — ver arriba.
## 3. Storefront — banner dinámico y buscador
Con ambas integraciones ya en producción, se atendieron dos pedidos de
UI del storefront relacionados con la experiencia de compra real:
### 3.1 Banner dinámico (`components/home/hero-carousel.tsx`)
Antes: tres slides con copy e imágenes fijas, hardcodeadas en el
componente, sin relación con el catálogo real. Ahora: `HomePage`
(`app/page.tsx`, que ya hace `getProducts()` del backend real) le pasa al
carrusel los productos marcados como `featured` en el admin (o, si no hay
ninguno, los mismos "tesoros" —nuevos o destacados— que ya usa la sección
de abajo, para no duplicar el criterio de selección); el carrusel arma
cada slide con la imagen, nombre, precio (`priceLabel`) y link reales del
producto, calculando el badge de descuento (`% OFF`) cuando
`compareAtPrice` existe. Si la tienda no tuviera ningún producto activo
(caso borde), cae a un slide estático de respaldo para no romper la
página. Autoplay, dots de paginación y animación quedan igual — solo
cambia el origen de los datos.
### 3.2 Buscador (`components/layout/navbar.tsx`)
El header en mobile competía el buscador con el logo y los tres iconos
de acción en una sola fila de grid (`grid-cols-[auto_1fr_auto]`),
quedando visualmente recortado — reportado por el usuario junto a una
captura de referencia del header mobile de Best Buy (fila de iconos
arriba, buscador a todo el ancho debajo, con micrófono y escaneo QR
inline).
**Decisión:** adoptar el mismo patrón de dos filas en mobile/tablet
(logo + iconos arriba, buscador a todo el ancho debajo) pero **sin** los
íconos de micrófono/QR — no hay búsqueda por voz ni por código de barras
implementada, y un ícono sin función real confunde más de lo que ayuda.
En desktop (`lg:` y arriba) se mantuvo la fila única original del grid
`[auto_1fr_auto]`: ahí ya sobraba espacio horizontal y partirla en dos
solo alargaría el header sin necesidad. Sin cambios en la lógica de
`PredictiveSearch` (autocompletado, debounce, navegación) — solo se
reubicó su contenedor según el breakpoint.
## Opciones consideradas (pago)
| Opción | A favor | En contra |
|---|---|---|
| PayU LatAm (ADR 0009) | Ya decidido, credenciales bien documentadas | Registro real redirige a Rapyd — la entidad "PayU LatAm" independiente ya no existe para un comercio nuevo |
| Rapyd | Dueño real del negocio ex-PayU LatAm, API moderna y bien documentada | API completamente distinta (HMAC, REST moderno) — no es una migración de credenciales, es una integración nueva desde cero; descartada en fase de plan por costo/encaje frente a ePayco |
| **ePayco** — elegido | 100% Davivienda desde 2024, mejor tarifa PSE con el banco ya disponible del comercio; SDK oficial cubre el protocolo inusual (login + cifrado AES por campo) | Dos productos bajo la misma marca (Apify vs. la API real usada) fáciles de confundir; doc pública desactualizada respecto al SDK real; reembolsos no implementados |
## Consecuencias
- Checkout real de punta a punta (pago + envío calculado) queda
desplegado y verificado con navegador real hasta la redirección al
banco de PSE (ver nota de implementación 2026-09-10 abajo), pero
**ambos proveedores siguen en modo sandbox** — no se ha completado ni
un pago real ni un envío real todavía, y el login bancario de PSE
tampoco se completó (falta credencial de prueba). Ver checklist
ampliado abajo.
- La precisión de la tarifa de envío depende de cargar peso/dimensiones
reales por producto — deuda de datos explícita, no de código; candidato
natural a extender el importador CSV existente con columnas nuevas.
- Reembolsos (ePayco) y devoluciones/cancelación de guía (EnvíoClick)
quedan fuera de alcance — fallan explícitamente en vez de fingir éxito.
- Revertir el pago implica deshabilitar el provider `epayco_pse` en la
región y volver al placeholder anterior; revertir el envío implica
volver la shipping option a `manual_manual`/tarifa fija — ninguna de
las dos requiere cambios de esquema de datos para revertirse.
- El banner y el buscador no introducen dependencias nuevas ni cambian
ninguna ruta de API — son cambios de storefront puros, reversibles
cambiando los dos componentes de vuelta a su versión anterior.
## Checklist de pruebas reales desde el storefront (pendiente, no ejecutado en este ADR)
Todo lo de arriba se verificó primero a nivel API/backend con llamadas
directas o carritos de prueba armados por API, sin manejar el
storefront real como lo haría un comprador. Esa parte del checklist se
cerró el 2026-09-10 (ver nota de implementación abajo) usando un
navegador real de verdad, no curl.
### RESUELTO (2026-09-10) — navegador real vía Docker en la NAS
El shell donde corren los comandos de esta sesión tiene su propia red
restringida para `npm` (un proxy interno que rompe algunas descargas de
paquetes — no relacionado con la red real del lab). **Docker en la NAS
sí sale a internet real, sin ese filtro** — confirmado en vivo
(`docker pull` normal, un contenedor de Node golpeando
`registry.npmjs.org` sin problema). Se armó un harness reutilizable con
la imagen oficial `mcr.microsoft.com/playwright:v1.51.1-jammy`
(Chromium real) corrida puntualmente contra `shop.cruzcloud.net`,
guardado en `.browser-scratch/` en la raíz del lab (`checks.mjs` +
`README.md` con el comando exacto para reactivarlo — no hace falta
ningún contenedor corriendo de forma persistente, la imagen y las
dependencias ya quedan cacheadas localmente en la NAS).
**Hallazgo no obvio:** el daemon de Docker de la NAS ve un filesystem
distinto al que ve el shell de esta sesión (que corre en su propio
sandbox, aunque monte los mismos repos git) — un volumen `-v` apuntando
a un path de `/tmp` del shell no tira error pero monta un directorio
vacío del lado del contenedor. Hay que usar un path bajo
`/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/` (montaje real
compartido) para que el contenedor vea los archivos de verdad.
**Resultados, con capturas reales:**
1. **Pago PSE completo — FUNCIONA.** Carrito → dirección Bogotá →
envío calculado → banco PSE (sandbox) → **la redirección real
funcionó**: el nombre de campo manejado a la defensiva en
`bank.create()`/`initiatePayment` matcheó al primer intento, y el
navegador llegó a la página real de autorización de PSE
(`registro.desarrollo.pse.com.co`, ambiente de desarrollo/sandbox
de PSE Colombia). No se completó el login bancario (requiere
credenciales de prueba específicas de PSE que no se tenían a mano) —
sigue pendiente confirmar el webhook con un pago sandbox completado
de principio a fin (ver checklist nuevo abajo).
2. **Envío calculado real — FUNCIONA.** El paso de pago mostró
`Envío $8.941` (mismo valor ya visto en la verificación por API de
la sección anterior) en vez del fijo de $15.000, para una dirección
real de Bogotá.
3. **Banner dinámico — funciona, pero destapó un bug real:** un
producto marcado `featured: true` pero **sin stock**
(`available: false`, "Toy Story 5 - Rex") salía primero en el
carrusel con botón "Comprar ahora" hacia una página donde en
realidad no se puede comprar. Corregido en el mismo commit que este
hallazgo (filtro adicional `&& product.available` en
`app/page.tsx`).
4. **Buscador mobile — funciona.** El buscador ya no se ve recortado,
y el autocompletado despliega bien debajo de la fila nueva a todo el
ancho (probado con el viewport de un iPhone 13 real, no devtools).
5. **Bug adicional encontrado, no buscado:** el sandbox real de ePayco
(`bank.getBanks()`) devuelve un primer elemento placeholder
(`bankCode: "0"`, `"A continuación seleccione su banco..."` con el
texto mal decodificado — unicode escapado literal) mezclado con los
bancos reales, mostrado como una opción más, seleccionable, en el
`<select>` del checkout. Ningún banco PSE real tiene código "0" —
se filtra explícitamente en `pse-banks/route.ts`.
Ampliado 2026-09-10 con el resto de la superficie real del storefront
(4 páginas + `components/{account,cart,catalog,checkout,home,layout,
product,search,wishlist}`), agrupado por riesgo, y ejecutado el mismo
día contra `shop.cruzcloud.net` con el harness de `.browser-scratch/`.
### RESUELTO (2026-09-11) — segunda tanda con navegador real
Resultados reales, uno por uno (código de cada prueba en
`checks.mjs`):
- **`/checkout` con carrito vacío (ítem 9) — OK.** Muestra "Tu carrito
está vacío" con link a `/catalog`, no rompe.
- **Fallback "Confirmar por WhatsApp" (ítem 10) — OK.** Abre
`api.whatsapp.com/send?phone=...` en pestaña nueva con el mensaje del
pedido armado correctamente.
- **Filtros de `/catalog` (ítem 11) — OK.** Un filtro de categoría real
("Figuras de acción") redujo los productos mostrados de 18 a 4. Nota:
estos filtros (categoría/marca/edad dentro de la barra lateral) son
**estado de cliente, no reflejado en la URL** — distinto de los
filtros por query param (`?category=...`) que sí usa la navegación
del header. Ninguno de los dos es un bug, son dos mecanismos
distintos coexistiendo; queda como nota para no confundirlos.
- **Búsqueda sin resultados (ítem 12) — BUG real encontrado y
corregido.** El dropdown de autocompletado no mostraba absolutamente
nada (ni "Buscando…" ni ningún mensaje) cuando una búsqueda de 2+
caracteres no tenía coincidencias — el usuario se quedaba sin
feedback de que su búsqueda había corrido. Corregido en
`predictive-search.tsx`: ahora muestra "No encontramos resultados
para…" con el botón de buscar en todo el catálogo.
- **Slug de producto inválido (ítem 14) — hallazgo, no corregido
todavía.** La página sí muestra el contenido correcto de "Este tesoro
no existe" (el código ya llama a `notFound()` de
`next/navigation`, como corresponde), **pero la respuesta HTTP real
es `200`, no `404`** — confirmado independientemente con `curl`, no
es un artefacto de Playwright. No se investigó a fondo en qué capa se
pierde el status real (Next.js standalone vs. alguna capa de
Cloudflare/NPM/Traefik en el medio, ver
`incidente-cloudflared-504-quic-2026-08.md` para precedente de
problemas de red en esa cadena) — bajo impacto en la experiencia del
usuario (el contenido es correcto), pero sí afecta SEO/crawlers.
Queda como pendiente de investigar, no de corregir a ciegas.
- **Zoom interactivo del producto (ítem 15) — OK.** Hover-zoom y el
modal de click-to-zoom siguen funcionando igual tras los cambios
recientes al storefront.
- **Wishlist/favoritos (ítem 17) — OK.** Agregar a favoritos +
recargar la página: persiste correctamente vía `localStorage`.
- **Otros viewports, tablet y desktop grande (ítem 19) — OK.**
Capturas guardadas, sin problemas de layout visibles en el banner ni
el header en esos anchos.
- **Página 404 genérica (ítem 20) — OK.** Una ruta que no existe cae en
`not-found.tsx` con status HTTP `404` real (a diferencia del caso de
producto inválido arriba — la diferencia de comportamiento entre
ambos casos es justamente lo que quedó como pendiente de investigar).
- **Accesibilidad básica (ítem 21) — OK, parcial.** El buscador es
alcanzable navegando solo con `Tab` desde el body, sin mouse. Falta
probar el resto del flujo de checkout por teclado.
- **Registro/login con email y contraseña (parte del ítem 16) — OK.**
Crear cuenta con nombre/apellido/email/contraseña funciona y deja la
sesión iniciada. La parte de Google OAuth sigue sin probarse (necesita
una cuenta de Google real, no algo que se pueda automatizar sin
credenciales).
### Pendiente — lo que queda sin probar
3. **Resiliencia del fallback de envío:** forzar una dirección o
producto que haga fallar la cotización real de EnvíoClick (ej. un
DANE code inválido) y confirmar que el checkout sigue avanzando con
el precio de respaldo ($15.000) en vez de romperse — comportamiento
documentado en `calculatePrice()`, nunca ejercitado con un fallo
real.
6. **Webhook de ePayco con un pago real de sandbox completado:**
conseguir credenciales de prueba reales de PSE (persona/clave de
algún banco del ambiente `desarrollo`), completar el login bancario
de punta a punta, y confirmar que el webhook mueve la orden a
completada sin intervención manual.
7. **Pago cancelado o rechazado en el banco PSE:** confirmar que la
orden no queda en un estado ambiguo ("colgada" en
`pending_authorization` para siempre).
8. **Persona jurídica + NIT** en el formulario de PSE — solo se probó
persona natural con cédula.
13. **Selección de variantes** (cuando un producto tiene más de una) y
que el precio/disponibilidad mostrado cambie según la variante
elegida — ningún producto probado hasta ahora tenía más de una
variante real.
14b. **Investigar por qué `/product/<slug-inválido>` responde HTTP 200**
en vez de 404 pese a llamar `notFound()` correctamente (ver
hallazgo arriba) — identificar la capa responsable antes de tocar
código.
16b. **Login con Google (OAuth completo)** incluyendo
`/auth/google/callback` — necesita una cuenta de Google real de
prueba.
18. **Bug conocido de Firefox Android** (ver memoria de proyecto,
`known-bug-firefox-android-layout`) — Playwright puede emular
Android/Chrome pero no Firefox; hace falta `playwright-firefox` o
un dispositivo real.
21b. **Accesibilidad del checkout completo** por teclado (no solo el
buscador) — tab order y foco visible en los 3 pasos.
@@ -0,0 +1,115 @@
# ADR 0011 — Ambiente de Staging para el storefront
**Estado:** aceptada — desplegado y verificado en vivo
**Fecha:** 2026-09-14
## Contexto
Hasta este punto, todo cambio de frontend se probaba directamente
contra `shop.cruzcloud.net` (producción): el mismo push a `main` que
disparaba el build de imagen también actualizaba el manifiesto real
que sirve tráfico. Cada iteración de UI (rediseño de la página de
producto, zoom estilo Amazon, ajustes mobile) implicaba validar en
vivo sobre el sitio real, sin una red de contención — un cambio roto
llegaba directo al usuario final antes de poder revertirlo.
El usuario pidió explícitamente un ambiente de Staging para dejar de
probar en producción ("Me interesa crear el ambiente Staging para no
estar probando en Prod").
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| **Namespace + Ingress propios, backend compartido con Prod** — elegida | Cero costo adicional de Postgres/Medusa/Meilisearch en un NAS ya ajustado de recursos; rápido de armar reusando el mismo patrón workload+governance de `ecommerce` | Staging y Prod comparten datos reales (catálogo, inventario, órdenes) — no aísla pruebas que muten datos de negocio |
| Backend propio para Staging (Postgres/Medusa/Meilisearch duplicados) | Aislamiento completo, se podrían probar migraciones de datos sin riesgo | Duplica el consumo de recursos de la NAS sin necesidad todavía (decisión explícita del usuario de descartar esto por ahora) |
| Feature flags / preview deploys por PR en vez de un ambiente fijo | Sin infraestructura nueva que mantener | Gitea Actions en este lab no tiene un mecanismo de preview-per-branch ya armado; más esfuerzo de tooling que un namespace nuevo |
## Decisión
Namespace `ecommerce-staging` nuevo, con su propio Ingress
(`staging.cruzcloud.net`) y su propio Deployment de frontend (misma
imagen Docker que Prod), pero apuntando al **mismo backend Medusa** que
producción (`medusa-svc.ecommerce.svc.cluster.local:9000` — FQDN
cross-namespace, un nombre corto solo resuelve dentro del mismo
namespace). Flujo de promoción: **todo push a `main` despliega a
Staging automáticamente**; promover a Producción es una acción manual
separada (`scripts/promote-to-production.sh`).
## Qué se armó
- `apps-registry/workloads/ecommerce-staging/` — overlay Kustomize:
1 réplica (no 2 como Prod — ambiente de verificación, no de tráfico
real), requests/limits más chicos, mismo `securityContext` que Prod
(mismas restricciones del Dockerfile: `runAsUser` numérico,
`NET_BIND_SERVICE`, `readOnlyRootFilesystem`).
- `apps-registry/apps/ecommerce-staging-app.yaml` +
`ecommerce-staging-governance-app.yaml` — Applications de Argo CD
(workload + governance separadas, misma convención que el resto del
lab).
- `platform-infra/ecommerce-staging-governance/` — ResourceQuota,
LimitRange y NetworkPolicy propios del namespace nuevo.
- `.gitea/workflows/build.yaml` — el paso de CD (`Actualizar
Manifiesto GitOps`) ahora apunta a
`workloads/ecommerce-staging/frontend/patches/deployment.yaml` en
vez del manifiesto de Prod: cada push a `main` bumpea el tag de
imagen (y, desde el mismo día, la variable `APP_VERSION` — ver nota
de implementación) de Staging automáticamente.
- `scripts/promote-to-production.sh` — lee el tag actualmente
desplegado en Staging, pide confirmación explícita, y lo aplica al
manifiesto real de Producción (`workloads/ecommerce/frontend/patches/deployment.yaml`).
Nunca corre automáticamente: es la única vía para que un cambio
llegue a `shop.cruzcloud.net`.
- Secrets `commerce-storefront` y `gitea-registry-secret` copiados
(no regenerados) desde el namespace `ecommerce` hacia
`ecommerce-staging`, para que el frontend de Staging tenga las
mismas credenciales reales contra el backend compartido.
## Descubrimiento de red real (no solo Kubernetes)
Confirmado en vivo el mismo día: aunque Cloudflare tiene un wildcard
`*.cruzcloud.net`, eso **no alcanza** para exponer un Ingress nuevo. La
cadena real es Cloudflare → túnel `cloudflared` → **Nginx Proxy
Manager** → `k3d-lab-cluster-serverlb` (puerto 90) → Traefik → Service.
Cada subdominio que ya funciona (`shop`, `commerce`, `media`, etc.)
tiene su propia fila de Proxy Host en NPM apuntando a
`192.168.68.61:90`. Un Ingress nuevo sin esa fila devuelve la página
por defecto de NPM aunque Traefik ya esté enrutando bien internamente
— verificado con `curl -H "Host: staging.cruzcloud.net"
http://192.168.68.61:90/` (funcionaba) contra el dominio público
(seguía devolviendo la página por defecto hasta que se agregó la fila
en NPM). Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
Esta fila de NPM la agregó el usuario manualmente — no hay credenciales
de ese panel disponibles para automatizarlo.
## Nota de implementación (2026-09-14): versión real visible en el footer
El footer mostraba un string fijo (`v4.1`), inútil para distinguir a
simple vista si Staging y Producción están en la misma versión.
Se agregó una variable de entorno `APP_VERSION` (leída en
`footer.tsx` vía `process.env.APP_VERSION`, sin necesidad de prefijo
`NEXT_PUBLIC_` por ser un Server Component) a ambos manifiestos de
Deployment; el mismo paso del CD que bumpea el tag de imagen ahora
también bumpea `APP_VERSION` (vía `perl -0777` para una sustitución
multilínea, ya que el nombre y el valor de la variable viven en líneas
distintas del YAML — un `sed` de una sola línea no alcanza).
`promote-to-production.sh` hace el mismo bump al promover. Verificado
en vivo con Playwright: tras el primer push, Staging mostró `v1.0.263`
mientras Producción seguía en `v4.1` (la imagen vieja no leía la
variable); tras promover, ambos ambientes quedaron visualmente
distinguibles por versión real en su footer.
## Consecuencias
- Ganado: ningún cambio de frontend vuelve a validarse por primera vez
contra tráfico real; Staging y Producción ahora se distinguen a
simple vista por la versión mostrada en el footer.
- Deuda aceptada: Staging comparte datos reales de negocio con
Producción (mismo Medusa/Postgres) — una prueba que cree o modifique
órdenes/inventario en Staging afecta datos reales. Si esto se vuelve
un problema, la opción descartada de backend propio para Staging
queda como camino de escalamiento.
- Para revertir: apuntar de nuevo `MANIFEST_FILE`/`KUSTOMIZE_DIR` en
`build.yaml` hacia `workloads/ecommerce/...` y eliminar el namespace
`ecommerce-staging` — el namespace de Producción no cambia de forma
en ningún momento de este ADR.
+2
View File
@@ -97,6 +97,8 @@ nav:
- "0007 · Identidad visual propia": decisiones/0007-identidad-visual-propia.md
- "0008 · Identidad Navy & Coral": decisiones/0008-identidad-navy-coral.md
- "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
- Plantilla ADR: decisiones/plantilla-adr.md
- Playbooks:
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.224
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.268
# 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
@@ -0,0 +1,51 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ecommerce-staging
resources:
- https://gitea.cruzcloud.net/devops/platform-templates.git//k8s-manifests/base?ref=main
patches:
# Mismo criterio que workloads/ecommerce/frontend/kustomization.yaml:
# sin anchors/aliases de YAML a propósito (el parser de Gitea Actions no
# los resuelve en "on:", pero acá aplica igual por consistencia con el
# resto del repo). Ingress propio via ingress-staging.yaml en vez del
# generico de 1-backend del base.
- target:
kind: Ingress
name: app-ingress
patch: |-
$patch: delete
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
- target:
kind: Deployment
name: app-deployment
path: patches/deployment.yaml
- target:
kind: Service
name: app-svc
path: patches/service.yaml
- target:
kind: Deployment
name: app-deployment
patch: |-
- op: replace
path: /metadata/name
value: frontend-deploy
- op: replace
path: /spec/template/spec/containers/0/ports
value:
- name: http
containerPort: 80
- target:
kind: Service
name: app-svc
patch: |-
- op: replace
path: /metadata/name
value: frontend-svc
@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-deployment
labels:
app: frontend
spec:
# 1 réplica (no 2 como Producción): Staging es para verificar cambios
# antes de promover, no para servir tráfico real -- alcanza, y deja más
# margen de recursos en la NAS para el resto del cluster.
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
securityContext:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.266
env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION
value: v1.0.266
- name: HEADLESS_PROVIDER
value: medusa
# Backend COMPARTIDO con Producción a propósito (decisión del
# usuario 2026-09-14: armar un backend propio para Staging
# -Postgres/Medusa/Meilisearch- duplicaría el consumo de la
# NAS sin necesidad todavía). FQDN cross-namespace: "ecommerce"
# es el namespace real de medusa-svc -- el nombre corto
# "medusa-svc" solo resuelve dentro del mismo namespace.
- name: MEDUSA_BACKEND_URL
value: http://medusa-svc.ecommerce.svc.cluster.local:9000
- name: MEDUSA_REGION_ID
value: reg_01KZT86WPAH7A7ZZRDSX3350V2
envFrom:
- secretRef:
name: commerce-storefront
readinessProbe:
httpGet:
path: /api/health
port: http
livenessProbe:
httpGet:
path: /api/health
port: http
# Mismo securityContext que Producción -- ver comentario largo en
# workloads/ecommerce/frontend/patches/deployment.yaml (misma
# imagen, mismos motivos: runAsUser numérico por el Dockerfile
# usando "nextjs" por nombre, NET_BIND_SERVICE por el puerto 80,
# volumenes por el cache de Next.js con root filesystem de solo
# lectura).
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
# Requests/limits mas chicos que Produccion -- una sola replica
# de verificacion, no trafico real.
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: app-svc
spec:
selector:
app: frontend
ports:
- name: http
port: 80
targetPort: 80
@@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ecommerce-staging-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: staging.cruzcloud.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-svc
port:
number: 80
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ecommerce-staging
resources:
- frontend/
- ingress-staging.yaml
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { MessageCircle, PackageOpen, ShieldCheck, Truck } from "lucide-react";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = { title: "Cambios | ARI Shopping" };
const WHATSAPP_URL = "https://wa.me/573016138155";
export default function CambiosPage() {
return (
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Cambios</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
Si tu pedido no es lo que esperabas, te ayudamos a cambiarlo.
</p>
<div className="mt-10 grid gap-6 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 p-6">
<PackageOpen className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">5 días hábiles</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Tenés 5 días hábiles desde que recibís tu pedido para solicitar un cambio.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<Truck className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Envío de vuelta sin costo</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
ARI Shopping asume el costo del envío de vuelta del producto — no tenés que pagar nada extra.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<ShieldCheck className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Condiciones</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
El producto debe estar sin usar, con su empaque y etiquetas originales.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<MessageCircle className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">¿Cómo lo pido?</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Escribinos por WhatsApp con tu número de pedido y coordinamos la recolección y el cambio.
</p>
</div>
</div>
<Button asChild variant="primary" size="lg" className="mt-10">
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<MessageCircle className="size-5" />
Solicitar un cambio por WhatsApp
</a>
</Button>
</section>
);
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { Mail, MapPin, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = { title: "Contacto | ARI Shopping" };
// Numero real confirmado en vivo (probando el boton "Confirmar por
// WhatsApp" del checkout con navegador real): mismo canal que ya usan
// los clientes para confirmar pedidos, consistencia a proposito.
const WHATSAPP_URL = "https://wa.me/573016138155";
// Correo corporativo nuevo ([email protected] -> reenviado por
// Cloudflare Email Routing a la casilla real) -- ver nota de
// implementacion en ADR 0010 para el paso manual de DNS pendiente.
const CONTACT_EMAIL = "[email protected]";
export default function ContactoPage() {
return (
<section className="mx-auto max-w-2xl px-4 py-14 text-center lg:py-20">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Contacto</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
¿Tenés una pregunta sobre tu pedido, un producto o un cambio? Escribinos, te respondemos lo antes posible.
</p>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
<a
href={WHATSAPP_URL}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 p-6 transition hover:border-ari-marino hover:bg-ari-marino/5"
>
<MessageCircle className="size-7 text-ari-marino" />
<div>
<p className="font-black">WhatsApp</p>
<p className="mt-1 text-sm text-slate-500">+57 301 613 8155</p>
</div>
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 p-6 transition hover:border-ari-marino hover:bg-ari-marino/5"
>
<Mail className="size-7 text-ari-marino" />
<div>
<p className="font-black">Correo</p>
<p className="mt-1 text-sm text-slate-500">{CONTACT_EMAIL}</p>
</div>
</a>
</div>
<div className="mt-10 flex items-center justify-center gap-2 text-sm text-slate-500">
<MapPin className="size-4" />
Despachamos desde Santiago de Cali, Colombia
</div>
<Button asChild variant="primary" size="lg" className="mt-10">
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
<MessageCircle className="size-5" />
Escribirnos por WhatsApp
</a>
</Button>
</section>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from "next";
import { Calculator, Clock, MapPinned, Truck } from "lucide-react";
export const metadata: Metadata = { title: "Envíos | ARI Shopping" };
// ADR 0010 §2: el costo de envio se calcula en vivo con EnvioClick (peso +
// direccion), no es un valor fijo -- el copy de abajo describe ese
// comportamiento real, no un placeholder. Transportadoras reales
// confirmadas en una cotizacion en vivo contra la API de EnvioClick
// (PIBOX, COORDINADORA, ENVIA, RAPPI, TCC) -- EnvioClick cotiza entre
// ellas y el sistema toma automaticamente la mas economica por pedido.
const CARRIERS = ["Coordinadora", "Envía", "TCC", "Rappi", "Pibox"];
export default function EnviosPage() {
return (
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Envíos</h1>
<p className="mt-5 text-lg leading-8 text-slate-600">
Hacemos envíos a nivel nacional desde nuestra bodega en Santiago de Cali, Valle del Cauca
</p>
<div className="mt-10 grid gap-6 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 p-6">
<Calculator className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Costo calculado en tiempo real</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
El valor del envío se calcula automáticamente según el peso de tu pedido y la dirección de entrega —
lo ves exacto en el checkout antes de pagar, nunca es una tarifa a ciegas.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<Truck className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Transportadoras aliadas</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Cotizamos automáticamente entre {CARRIERS.join(", ")} y asignamos la tarifa más conveniente para cada
pedido.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<Clock className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Tiempos de entrega</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Varían según la transportadora asignada y tu ciudad — una vez despachado tu pedido te compartimos el
número de guía para hacerle seguimiento.
</p>
</div>
<div className="rounded-2xl border border-slate-200 p-6">
<MapPinned className="size-6 text-ari-marino" />
<h2 className="mt-3 font-black">Cobertura</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Envíos a todo el territorio nacional colombiano desde nuestra bodega en Santiago de Cali, Valle del Cauca
</p>
</div>
</div>
<p className="mt-10 text-sm text-slate-500">
¿Tenés dudas puntuales sobre un pedido? Escribinos por{" "}
<a href="/contacto" className="font-bold text-ari-marino hover:underline">
nuestros canales de contacto
</a>
.
</p>
</section>
);
}
+11 -1
View File
@@ -18,7 +18,17 @@ export const dynamic = "force-dynamic";
export default async function HomePage() {
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroCarousel /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
// ADR 0010: el banner toma primero productos destacados (curaduria
// explicita del admin via el flag "featured"); si no hay ninguno,
// cae a los mismos "tesoros" (nuevos/destacados) de la seccion de
// abajo para no repetir la logica de seleccion. Se exige ademas
// "available" -- encontrado probando en vivo con un navegador real
// (Playwright): un producto featured pero sin stock disponible salio
// primero en el carrusel con boton "Comprar ahora" hacia una pagina
// donde en realidad no se puede comprar.
const featuredProducts = products.filter((product) => product.featured && product.available);
const bannerProducts = featuredProducts.length > 0 ? featuredProducts : treasures.filter((product) => product.available);
return <><HeroCarousel products={bannerProducts} /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
asset nuevo -- se reconstruye el mismo efecto (fondo punteado +
fotos estilo polaroid) con CSS y fotos reales de producto ya
existentes, en azul/amarillo de marca en vez del violeta original
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
export const metadata: Metadata = { title: "Preguntas frecuentes | ARI Shopping" };
// Solo preguntas cuya respuesta es un hecho real del storefront (ver
// ADR 0009/0010) -- nada de metodos de pago, plazos u opciones que no
// esten realmente implementados.
const FAQS: { question: string; answer: React.ReactNode }[] = [
{
question: "¿Qué métodos de pago aceptan?",
answer: "Pagás por PSE, con transferencia directa desde tu banco. Podés pagar como persona natural o jurídica.",
},
{
question: "¿A qué ciudades hacen envíos?",
answer: "A todo el territorio nacional colombiano. El costo del envío se calcula automáticamente según tu dirección y lo ves en el checkout antes de pagar.",
},
{
question: "¿Cuánto tarda en llegar mi pedido?",
answer: (
<>
Depende de la transportadora asignada y tu ciudad — más detalle en{" "}
<Link href="/envios" className="font-bold text-ari-marino hover:underline">
Envíos
</Link>
.
</>
),
},
{
question: "¿Puedo cambiar un producto que no me sirvió?",
answer: (
<>
Sí, tenés 5 días hábiles desde que lo recibís y nosotros asumimos el envío de vuelta — más detalle en{" "}
<Link href="/cambios" className="font-bold text-ari-marino hover:underline">
Cambios
</Link>
.
</>
),
},
{
question: "Tengo otra pregunta, ¿cómo los contacto?",
answer: (
<>
Escribinos por WhatsApp o correo desde{" "}
<Link href="/contacto" className="font-bold text-ari-marino hover:underline">
Contacto
</Link>
, te respondemos lo antes posible.
</>
),
},
];
export default function PreguntasFrecuentesPage() {
return (
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Preguntas frecuentes</h1>
<Accordion type="single" collapsible className="mt-10">
{FAQS.map((faq) => (
<AccordionItem key={faq.question} value={faq.question}>
<AccordionTrigger>{faq.question}</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
);
}
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
import { ProductCard } from "@/components/product/product-card";
import { ProductCarousel } from "@/components/product/product-carousel";
import { ProductGallery } from "@/components/product/product-gallery";
import { ProductPurchase } from "@/components/product/product-purchase";
@@ -24,7 +24,17 @@ export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const product = await getProductBySlug(slug);
return product ? { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } } : { title: "Producto" };
// notFound() se llama aquí (no solo en el componente de abajo) porque
// generateMetadata se resuelve por completo ANTES de que Next empiece a
// transmitir la respuesta -- el <Suspense> del navbar (categorías, ver
// navbar.tsx) hace que el layout raíz empiece a stream-ear con status 200
// antes de que un notFound() lanzado dentro del componente de la página
// pueda cambiarlo (confirmado en vivo: cualquier slug inexistente devolvía
// HTTP 200 con el contenido de app/not-found.tsx, rompiendo SEO/crawlers
// que sí miran el status code). Llamarlo aquí evita el problema porque esta
// fase bloquea el stream por diseño.
if (!product) notFound();
return { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } };
}
export default async function ProductPage({ params }: ProductPageProps) {
@@ -33,5 +43,84 @@ export default async function ProductPage({ params }: ProductPageProps) {
if (!product) notFound();
const products = await getProducts();
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
return <><section className="mx-auto grid max-w-7xl gap-10 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16"><ProductGallery images={product.images} name={product.name} /><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span><h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1><p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p><div className="mt-8"><ProductPurchase product={product} /></div><Accordion type="single" collapsible className="mt-8"><AccordionItem value="details"><AccordionTrigger>Detalles del producto</AccordionTrigger><AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent></AccordionItem><AccordionItem value="shipping"><AccordionTrigger>Envíos y entregas</AccordionTrigger><AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent></AccordionItem></Accordion></div></section>{recommended.length > 0 && <section className="mx-auto max-w-7xl px-4 pb-24"><h2 className="mb-7 text-3xl font-black">También te puede gustar</h2><ProductCarousel products={recommended} /></section>}<MobileAddToCart product={product} /></>;
// Categoría distinta a propósito -- antes repetía los primeros 2 del
// mismo "recommended" que ya alimenta el carrusel de abajo (mismos
// productos en dos lugares de la página, reportado por el usuario).
// Un cross-sell real de otra categoría aporta algo que el carrusel no
// muestra, en vez de duplicarlo.
const crossCategory = products.filter((candidate) => candidate.id !== product.id && candidate.category !== product.category);
const crossSell = crossCategory.find((candidate) => candidate.available) ?? crossCategory[0];
return (
<>
{/* max-w-[1600px] (antes max-w-7xl/1280px) y una 3ra columna desde
xl -- aprovecha pantallas grandes en vez de dejar el buy box
apretado contra el texto o cortado por el panel de zoom de al
lado (ver ProductGallery). Por debajo de xl, el buy box cae al
flujo normal en la 2da columna, sin duplicar el componente en
el DOM.
El buy box ocupa las 2 filas (xl:row-span-2) para no quedar
atado a la altura de galería+descripción -- "También te puede
gustar" pasa a ser la 2da fila debajo de esas dos columnas
(xl:col-span-2), en paralelo al buy box sticky, en vez de
esperar a que termine la columna más alta antes de aparecer. */}
<section className="mx-auto grid max-w-[1600px] gap-x-10 gap-y-12 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16 xl:grid-cols-[1.1fr_.75fr_360px] xl:grid-rows-[auto_auto]">
<ProductGallery images={product.images} name={product.name} />
<div className="min-w-0">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
{/* Antes escondido detrás de un click en el acordeón --
siempre visible, estilo Amazon, con los mismos datos
reales que ya existían (marca/edad/categoría/colección),
sin inventar specs que el catálogo no tiene. */}
<h2 className="mb-4 mt-8 text-sm font-black uppercase tracking-wide text-slate-500">Detalles del producto</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-2xl border border-slate-200 p-5 text-sm sm:grid-cols-4">
<div><dt className="text-slate-400">Marca</dt><dd className="mt-0.5 font-bold text-slate-900">{product.brand}</dd></div>
<div><dt className="text-slate-400">Edad recomendada</dt><dd className="mt-0.5 font-bold text-slate-900">{product.ageRange}</dd></div>
<div><dt className="text-slate-400">Categoría</dt><dd className="mt-0.5 font-bold text-slate-900">{product.category}</dd></div>
<div><dt className="text-slate-400">Colección</dt><dd className="mt-0.5 font-bold text-slate-900">{product.collection}</dd></div>
</dl>
<Accordion type="single" collapsible className="mt-6">
<AccordionItem value="shipping">
<AccordionTrigger>Envíos y entregas</AccordionTrigger>
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
tarjeta, sticky desde xl mientras el resto de la pagina
scrollea. El cross-sell es de OTRA categoría a propósito --
evita repetir lo que ya muestra el carrusel de "También te
puede gustar" de abajo (mismo criterio de categoría/colección
que ese carrusel), aportando un producto genuinamente distinto
en vez de duplicado. */}
<div className="min-w-0 xl:sticky xl:top-24 xl:row-span-2 xl:self-start">
<div className="rounded-2xl border border-slate-200 p-6 shadow-sm">
<ProductPurchase product={product} />
</div>
{crossSell && (
<div className="mt-6">
<h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2>
<ProductCard product={crossSell} />
</div>
)}
</div>
{/* min-w-0 es necesario: sin esto, el carrusel horizontal
(overflow-x-auto interno) contaba como item de este grid, y su
ancho minimo intrinseco (la suma de TODAS las cards sin
wrappear, ya que el scroller no tiene flex-wrap) empujaba todo
el grid completo a ~800px en mobile -- afectaba a TODA la
columna compartida (imagen, titulo, specs), no solo al
carrusel. Confirmado en vivo: sin min-w-0 el grid medía 783px
en un viewport real de 380px; con min-w-0 vuelve a 380px. */}
{recommended.length > 0 && (
<div className="min-w-0 pb-16 lg:col-span-2">
<h2 className="mb-7 text-3xl font-black">También te puede gustar</h2>
<ProductCarousel products={recommended} />
</div>
)}
</section>
</>
);
}
+9 -8
View File
@@ -38,13 +38,14 @@ data:
# reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY
# va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint
# de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa
# tarifas reales, no tiene modo sandbox. Origen = bodega Bogotá
# ("Bodega principal ARI Shopping", sin dirección física real cargada
# todavía -- ENVIOCLICK_ORIGIN_ADDRESS es un placeholder hasta que la
# haya).
# tarifas reales, no tiene modo sandbox. Origen = bodega Santiago de
# Cali, Valle del Cauca ("Bodega principal ARI Shopping") -- corregido
# 2026-09-12: el código DANE cargado originalmente era el de Bogotá
# (11001000) por error; toda cotización real hecha antes de esta
# fecha calculó la tarifa desde el origen equivocado.
ENVIOCLICK_SANDBOX: "true"
ENVIOCLICK_ORIGIN_DANE_CODE: "11001000"
ENVIOCLICK_ORIGIN_ADDRESS: "Calle 1 No 1-1"
ENVIOCLICK_ORIGIN_DANE_CODE: "76001000"
ENVIOCLICK_ORIGIN_ADDRESS: "Cra 100 #42-65"
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
ENVIOCLICK_ORIGIN_EMAIL: "contacto@ari-shopping.com"
ENVIOCLICK_ORIGIN_PHONE: "3000000000"
ENVIOCLICK_ORIGIN_EMAIL: "cristiancruz29@outlook.com"
ENVIOCLICK_ORIGIN_PHONE: "3014750823"
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -4,15 +4,33 @@ import Image from "next/image";
import Link from "next/link";
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
// ADR 0008 sub-fase 4: hero azul con dots de paginacion (carrusel), en vez
// del bento estatico de 3 tarjetas en fucsia/rojo/violeta del esquema
// arcoiris original. Un slide a la vez, fondo azul de marca en los tres,
// para leerse como una sola pieza de marca en vez de 3 bloques de colores
// distintos.
const slides = [
//
// ADR 0010: los slides ya no son un array hardcodeado -- se derivan de
// productos activos reales del catalogo (pasados por prop desde
// app/page.tsx, que ya trae `products` del backend). Fallback estatico
// solo para el caso borde de una tienda sin ningun producto activo.
type Slide = {
id: string;
badge: string;
icon: typeof Sparkles;
title: string;
subtitle: string;
description: string;
href: string;
image: string;
imageAlt: string;
};
const FALLBACK_SLIDES: Slide[] = [
{
id: "celebracion",
badge: "Celebración ARI",
@@ -48,17 +66,46 @@ const slides = [
},
];
const MAX_SLIDES = 5;
const AUTOPLAY_MS = 6000;
export function HeroCarousel() {
function slidesFromProducts(products: Product[]): Slide[] {
return products.slice(0, MAX_SLIDES).map((product) => {
const hasDiscount = product.compareAtPrice !== null && product.compareAtPrice > (product.price ?? 0);
const pctOff = hasDiscount && product.price !== null
? Math.round(100 - (product.price / (product.compareAtPrice as number)) * 100)
: 0;
return {
id: product.id,
badge: hasDiscount ? `${pctOff}% OFF` : product.collection ? `Colección ${product.collection}` : "Destacado",
icon: hasDiscount ? BadgePercent : Sparkles,
title: product.name,
subtitle: product.priceLabel,
description: product.shortDescription || "Descúbrelo en la tienda ARI Shopping.",
href: `/product/${product.slug}`,
image: product.image,
imageAlt: product.name,
};
});
}
export function HeroCarousel({ products }: { products: Product[] }) {
const reduceMotion = useReducedMotion();
const [index, setIndex] = useState(0);
const slides = useMemo(() => {
const fromProducts = slidesFromProducts(products);
return fromProducts.length > 0 ? fromProducts : FALLBACK_SLIDES;
}, [products]);
useEffect(() => {
if (reduceMotion) return;
const timer = setInterval(() => setIndex((current) => (current + 1) % slides.length), AUTOPLAY_MS);
return () => clearInterval(timer);
}, [reduceMotion]);
}, [reduceMotion, slides.length]);
useEffect(() => {
if (index >= slides.length) setIndex(0);
}, [slides.length, index]);
const slide = slides[index];
if (!slide) return null;
@@ -1,10 +1,42 @@
import Link from "next/link";
import { Facebook, Instagram, MessageCircle } from "lucide-react";
// APP_VERSION la actualiza el propio CI (.gitea/workflows/build.yaml) al
// mismo tiempo que el tag de la imagen -- antes decia "v4.1" fijo,
// inutil para distinguir a simple vista si Staging y Produccion estan
// en la misma version o no.
const appVersion = process.env.APP_VERSION || "dev";
// Las 4 paginas de "Ayuda" ya tienen contenido real (app/envios,
// app/cambios, app/preguntas-frecuentes, app/contacto).
const HELP_LINKS = [
{ label: "Preguntas frecuentes", href: "/preguntas-frecuentes" },
{ label: "Envíos", href: "/envios" },
{ label: "Cambios", href: "/cambios" },
{ label: "Contacto", href: "/contacto" },
];
// Cuentas reales confirmadas por el usuario (Instagram verificado en
// vivo antes de linkearlo -- la primera cuenta sugerida, @arishopping,
// no era la real). El link de WhatsApp usa el mismo numero real ya
// confirmado en /contacto y en el fallback del checkout.
const SOCIAL_LINKS = [
{ label: "Instagram", href: "https://www.instagram.com/ari_shopping1/", Icon: Instagram },
{ label: "Facebook", href: "https://www.facebook.com/share/1Dkmvko6HF/", Icon: Facebook },
{ label: "WhatsApp", href: "https://wa.me/573016138155", Icon: MessageCircle },
];
const SHOP_LINKS = [
{ label: "Novedades", href: "/catalog" },
{ label: "Juguetes", href: "/catalog" },
{ label: "Mochilas", href: "/catalog" },
{ label: "Regalos", href: "/catalog" },
];
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"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><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 v4.1 · 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} /><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>;
}
function FooterColumn({ title, links }: { title: string; links: string[] }) {
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link} href="/catalog" className="hover:text-ari-coral-claro">{link}</Link>)}</div></div>;
function FooterColumn({ title, links }: { title: string; links: { label: string; href: string }[] }) {
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link.label} href={link.href} className="hover:text-ari-coral-claro">{link.label}</Link>)}</div></div>;
}
@@ -55,34 +55,47 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<div className="relative overflow-visible bg-white">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
<div className="mx-auto grid max-w-7xl grid-cols-[auto_1fr_auto] items-center gap-4 px-4 py-4 lg:gap-8">
{/* 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} />
</Link>
<PredictiveSearch />
<div className="flex items-center gap-2">
{/* Antes escondido en mobile (solo alcanzable desde el menu
hamburguesa) -- pedido explicito del usuario: visible
siempre en el header, con un punto verde cuando hay sesion
iniciada (mismo patron que el badge de cantidad del
carrito/favoritos, pero de presencia, no de conteo). */}
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
<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>}
</Button>
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
<ShoppingCart />
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
</Button>
{/* ADR 0010: en mobile/tablet el buscador se movio a una segunda
fila a todo el ancho, debajo de logo+iconos (antes competia por
espacio en una sola fila con el grid_[auto_1fr_auto] y quedaba
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">
{/* 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} />
</Link>
<div className="hidden lg:block">
<PredictiveSearch />
</div>
<div className="flex items-center gap-2">
{/* Antes escondido en mobile (solo alcanzable desde el menu
hamburguesa) -- pedido explicito del usuario: visible
siempre en el header, con un punto verde cuando hay sesion
iniciada (mismo patron que el badge de cantidad del
carrito/favoritos, pero de presencia, no de conteo). */}
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
<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>}
</Button>
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
<ShoppingCart />
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
</Button>
</div>
</div>
<div className="mt-3 lg:hidden">
<PredictiveSearch />
</div>
</div>
</div>
@@ -1,25 +0,0 @@
"use client";
import { ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store";
export function MobileAddToCart({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem);
const outOfStock = product.variants[0]?.available === false;
return (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden">
<Button
variant="primary"
size="lg"
className="w-full"
disabled={outOfStock}
onClick={() => addItem(product, product.variants[0]?.id)}
>
<ShoppingBag />
{outOfStock ? "Agotado" : "Añadir al carrito"}
</Button>
</div>
);
}
@@ -7,28 +7,82 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
interface Offset { x: number; y: number }
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
interface HoverZoomOrigin { x: number; y: number }
interface HoverZoomState {
lensLeft: number;
lensTop: number;
lensSize: number;
bgX: number;
bgY: number;
showPanel: boolean;
panelLeft: number;
panelTop: number;
panelSize: number;
}
// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce)
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma
// continua en touch, asi que en mobile simplemente no aparece el efecto,
// sin romper nada). Independiente del zoom interactivo del modal
// (click -> Dialog con scale/drag), que sigue igual.
// Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la
// imagen (la lupa) marca la region que se esta mirando, y un panel
// aparte a la derecha muestra esa misma region ampliada -- sin recortar
// nada de la pantalla ni pedirle al usuario que haga scroll para verlo
// completo (a diferencia de un primer intento que escalaba la imagen
// in-place con transform, donde para ver el borde superior había que
// mover el cursor fuera del viewport visible). Solo mouse (mousemove no
// dispara de forma continua en touch); en mobile no aparece el efecto,
// sin romper nada. Independiente del zoom interactivo del modal (click
// -> Dialog con scale/drag), que sigue igual.
//
// El panel usa background-image con la foto completa (no la version ya
// recortada por object-cover) a proposito -- escalar la imagen recortada
// nunca iba a revelar las partes recortadas (ej. la cabeza de una figura
// de accion en una foto vertical). background-size en un solo valor
// (ancho%, alto en "auto") preserva el aspect ratio real de la foto
// completa.
//
// El tamaño/posicion del panel se calculan en cada mousemove contra el
// viewport real (window.innerWidth/innerHeight), no con un breakpoint
// fijo tipo "lg:block" -- a resoluciones altas con la ventana angosta
// (o el layout de la pagina dejando poco espacio a la derecha de la
// imagen) un tamaño fijo terminaba saliéndose de la pantalla. Si no
// entra un panel de al menos PANEL_MIN_SIZE, se cae automáticamente al
// overlay in-place (misma imagen completa como fondo, dentro de la
// propia caja) en vez de mostrar un panel cortado.
const HOVER_ZOOM_SCALE = 2.2;
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE;
const PANEL_MARGIN = 16;
const PANEL_MAX_SIZE = 480;
const PANEL_MIN_SIZE = 220;
const VIEWPORT_EDGE_PADDING = 16;
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
const [selected, setSelected] = useState(images[0] ?? "");
const [open, setOpen] = useState(false);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
const [hoverZoom, setHoverZoom] = useState<HoverZoomState | null>(null);
const drag = useRef<DragState | null>(null);
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const lensSize = rect.width * LENS_RATIO;
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const lensLeft = Math.min(Math.max(cursorX - lensSize / 2, 0), rect.width - lensSize);
const lensTop = Math.min(Math.max(cursorY - lensSize / 2, 0), rect.height - lensSize);
const availableWidth = window.innerWidth - rect.right - PANEL_MARGIN - VIEWPORT_EDGE_PADDING;
const availableHeight = window.innerHeight - VIEWPORT_EDGE_PADDING * 2;
const panelSize = Math.min(PANEL_MAX_SIZE, availableWidth, availableHeight);
const panelTop = Math.min(Math.max(rect.top, VIEWPORT_EDGE_PADDING), window.innerHeight - panelSize - VIEWPORT_EDGE_PADDING);
setHoverZoom({
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
lensLeft,
lensTop,
lensSize,
bgX: (lensLeft / (rect.width - lensSize)) * 100,
bgY: (lensTop / (rect.height - lensSize)) * 100,
showPanel: panelSize >= PANEL_MIN_SIZE,
panelLeft: rect.right + PANEL_MARGIN,
panelTop,
panelSize,
});
};
@@ -55,38 +109,90 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
};
return (
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
<div className="min-w-0 grid gap-4 lg:grid-cols-[86px_1fr]">
{/* min-w-0: esta tira de miniaturas es un overflow-x-auto (scroll
horizontal) igual que el carrusel de "También te puede gustar"
en app/product/[slug]/page.tsx -- mismo bug si este componente
termina como item de un grid externo sin min-w-0: para productos
con muchas fotos, el ancho minimo intrinseco de TODAS las
miniaturas sin wrappear puede forzar overflow horizontal en toda
la pagina en mobile. */}
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}
{/* object-contain (no object-cover): fotos de producto con relacion
de aspecto muy vertical (ej. 933x1500, confirmado en vivo para
este catalogo) perdian la cabeza o los pies recortados al forzar
el cuadrado con cover -- reportado por el usuario viendo las
miniaturas. contain deja la miniatura completa siempre, con
"letterbox" del mismo bg-slate-50 que usa la imagen principal. */}
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} onMouseEnter={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 bg-slate-50 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-contain" /></button>)}
</div>
<Dialog open={open} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<button
type="button"
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
onMouseMove={trackHoverZoom}
onMouseLeave={() => setHoverZoom(null)}
>
<Image
src={selected}
alt={name}
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover transition-transform duration-150 ease-out"
style={{
transform: `scale(${hoverZoom ? HOVER_ZOOM_SCALE : 1})`,
transformOrigin: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center",
}}
/>
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
</button>
</DialogTrigger>
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
<div className="relative order-1 lg:order-2">
<Dialog open={open} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<button
type="button"
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
onMouseMove={trackHoverZoom}
onMouseLeave={() => setHoverZoom(null)}
>
{/* unoptimized a proposito: confirmado en vivo (createImageBitmap
sobre los bytes reales vs. el <img> renderizado) que a DPR
alto (2.6+, cualquier Android real) Chromium reporta
naturalWidth/Height de un candidato del srcset mucho más
chico del que dice currentSrc -- bug del propio motor de
Chromium en la selección de candidato con sizes="100vw" en
fill, no algo corrupto en el archivo ni en nuestro caché
(el archivo decodifica perfecto vía fetch+createImageBitmap
fuera de <img>). Reproducido igual en Chrome y Edge Android
(ambos Chromium) y en Chromium headless. unoptimized deja
un solo recurso real por foto -- sin candidatos entre los
que el bug pueda elegir mal. */}
{/* object-contain (no object-cover): mismo motivo que las
miniaturas -- fotos verticales (933x1500 confirmado en
este catalogo) perdian la cabeza al forzar el cuadrado.
Reportado en vivo: al seleccionar esa foto, Buzz aparecia
sin cabeza en la imagen principal. */}
<Image
src={selected}
alt={name}
fill
priority
unoptimized
className="object-contain"
/>
{/* Lupa: recuadro que marca la region ampliada -- solo cuando
hay lugar real para el panel de al lado (showPanel,
calculado contra el viewport real, no un breakpoint
fijo). Sin espacio para el panel se usa el overlay
in-place como fallback, para cualquier resolucion. */}
{hoverZoom?.showPanel && (
<div
aria-hidden
data-testid="gallery-lens"
className="pointer-events-none absolute border-2 border-white bg-white/25 shadow-lg"
style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
/>
)}
<div
aria-hidden
data-testid="gallery-inplace-overlay"
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})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "center",
}}
/>
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
</button>
</DialogTrigger>
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
<Image src={selected} alt={name} fill unoptimized className="select-none object-contain" draggable={false} />
</div>
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
</div>
@@ -98,8 +204,27 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
</div>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
{hoverZoom?.showPanel && (
<div
aria-hidden
data-testid="gallery-zoom-panel"
className="pointer-events-none fixed z-30 overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl"
style={{
left: hoverZoom.panelLeft,
top: hoverZoom.panelTop,
width: hoverZoom.panelSize,
height: hoverZoom.panelSize,
backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
}}
/>
)}
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
import { CheckCircle2, RotateCcw, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
@@ -40,11 +41,14 @@ export function ProductPurchase({ product }: { product: Product }) {
<strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>
{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}
</div>
{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}
{/* Un solo botón, visible también en mobile -- sin barra flotante
aparte (se probó, pero terminaba tapando contenido de abajo al
scrollear, ej. "También te puede interesar"; el usuario pidió
sacarla y dejar el botón normal como en la mayoría de tiendas). */}
<Button
variant="primary"
size="lg"
className="hidden w-full lg:inline-flex"
className="w-full"
disabled={outOfStock}
onClick={() => addItem(product, variantId)}
>
@@ -73,6 +77,21 @@ export function ProductPurchase({ product }: { product: Product }) {
Envíos nacionales
</span>
</div>
{/* Franja de confianza real (estilo Walmart) -- enlaza a las
páginas reales de /envios y /cambios en vez de repetir texto
suelto, con los datos reales que confirmó el usuario (5 días
hábiles, envío de vuelta a cargo de ARI Shopping). Nada
inventado: mismas políticas ya publicadas en el footer. */}
<div className="mt-3 grid gap-2 text-sm">
<Link href="/envios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
<Truck className="size-4 text-cyan-600" />
Envíos calculados en el checkout · ver detalles
</Link>
<Link href="/cambios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
<RotateCcw className="size-4 text-ari-marino" />
Cambios en 5 días hábiles, envío de vuelta gratis
</Link>
</div>
</div>
);
}
@@ -87,6 +87,18 @@ export function PredictiveSearch() {
Buscando…
</div>
)}
{/* Encontrado probando en vivo con navegador real: sin este bloque,
una busqueda de 2+ caracteres sin coincidencias no mostraba nada
(ni "buscando" ni un mensaje) -- el usuario se quedaba sin ningun
feedback de que su busqueda de verdad corrio. Se ofrece igual el
boton de buscar en todo el catalogo porque esa busqueda completa
puede usar criterios mas amplios que el autocompletado. */}
{!loading && normalized.length >= 2 && matches.length === 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-2 text-center shadow-2xl">
<p className="p-3 text-sm text-slate-500">No encontramos resultados para “{query.trim()}”.</p>
<button type="submit" className="w-full rounded-xl bg-ari-marino px-4 py-3 text-sm font-black text-white hover:brightness-110">Buscar “{query.trim()}” en todo el catálogo</button>
</div>
)}
{matches.length > 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
{matches.map((product) => (
@@ -25,8 +25,14 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.230
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.263
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
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
+54 -41
View File
@@ -16,7 +16,7 @@
"clsx": "2.1.1",
"lucide-react": "0.544.0",
"motion": "12.23.24",
"next": "15.5.10",
"next": "15.5.24",
"react": "19.1.5",
"react-dom": "19.1.5",
"tailwind-merge": "3.3.1",
@@ -728,9 +728,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.10",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/env/-/env-15.5.10.tgz",
"integrity": "sha512-plg+9A/KoZcTS26fe15LHg+QxReTazrIOoKKUC3Uz4leGGeNPgLHdevVraAAOX0snnUs3WkRx3eUQpj9mreG6A==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.24.tgz",
"integrity": "sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -744,9 +744,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.24.tgz",
"integrity": "sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==",
"cpu": [
"arm64"
],
@@ -760,9 +760,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.24.tgz",
"integrity": "sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==",
"cpu": [
"x64"
],
@@ -776,12 +776,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.24.tgz",
"integrity": "sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -792,12 +795,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.24.tgz",
"integrity": "sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -808,12 +814,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.24.tgz",
"integrity": "sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -824,12 +833,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.24.tgz",
"integrity": "sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -840,9 +852,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.24.tgz",
"integrity": "sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==",
"cpu": [
"arm64"
],
@@ -856,9 +868,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.24.tgz",
"integrity": "sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==",
"cpu": [
"x64"
],
@@ -5394,11 +5406,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.10",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/next/-/next-15.5.10.tgz",
"integrity": "sha512-r0X65PNwyDDyOrWNKpQoZvOatw7BcsTPRKdwEqtc9cj3wv7mbBIk9tKed4klRaFXJdX0rugpuMTHslDrAU1bBg==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.24.tgz",
"integrity": "sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.10",
"@next/env": "15.5.24",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5411,15 +5424,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.3"
"@next/swc-darwin-arm64": "15.5.24",
"@next/swc-darwin-x64": "15.5.24",
"@next/swc-linux-arm64-gnu": "15.5.24",
"@next/swc-linux-arm64-musl": "15.5.24",
"@next/swc-linux-x64-gnu": "15.5.24",
"@next/swc-linux-x64-musl": "15.5.24",
"@next/swc-win32-arm64-msvc": "15.5.24",
"@next/swc-win32-x64-msvc": "15.5.24",
"sharp": "^0.34.3 || ^0.35.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
+1 -1
View File
@@ -19,7 +19,7 @@
"clsx": "2.1.1",
"lucide-react": "0.544.0",
"motion": "12.23.24",
"next": "15.5.10",
"next": "15.5.24",
"react": "19.1.5",
"react-dom": "19.1.5",
"tailwind-merge": "3.3.1",