Compare commits

..
Author SHA1 Message Date
devops 777e47ce16 Merge branch 'fix/footer-newsletter-overflow-320-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 27s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 17m33s
2026-09-20 18:09:35 +00:00
devops 167ae4057a fix(ecommerce): min-w-0 en Newsletter del footer, forzaba overflow a 320px 2026-09-20 18:09:10 +00:00
devops 51300c234a chore: promote v1.0.271 to production [skip ci] 2026-09-20 17:39:43 +00:00
devops 0c0efe3727 chore: release v1.0.271 [skip ci] 2026-09-20 17:39:42 +00:00
devops e4dafe9017 Merge branch 'ci/automate-staging-to-prod-promotion-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m47s
2026-09-20 17:30:00 +00:00
devopsandClaude Sonnet 5 fed5dfc1c1 ci(ecommerce): automatizar promocion de Staging a Produccion
Agrega el step "Promover a Produccion (CD)" al mismo job que ya
despliega a Staging en build.yaml -- misma logica de
scripts/promote-to-production.sh (sed/perl sobre el manifest de
Produccion) pero sin el read -rp interactivo, gateado por la misma
verificacion de promocion segura (merge-base --is-ancestor) que ya usa
el step de Staging.

Decision explicita del usuario 2026-09-20: cero gate humano entre
Staging y Produccion -- la promocion manual deja de ser el camino
normal, scripts/promote-to-production.sh queda solo como herramienta
de excepcion/rollback.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 17:27:37 +00:00
devops 5773154db9 chore: release v1.0.270 [skip ci] 2026-09-20 17:04:00 +00:00
devopsandClaude Sonnet 5 553b1c7a32 Merge branch 'fix/medusa-fetch-cache-multi-pod-2026-09-20'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m9s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:50:25 +00:00
devopsandClaude Sonnet 5 d9c39ba2a1 fix(ecommerce): sacar cache de fetch que competia contra force-dynamic
medusaFetch() usaba next:{revalidate:60,tags:["medusa-products"]}, cache
en memoria por-pod que sobrevivia a force-dynamic en las paginas que lo
consumen. Con frontend-deploy en 2 replicas sin cacheHandler compartido,
el POST de /api/revalidate del subscriber de Medusa solo llegaba a uno
de los dos pods, dejando al otro sirviendo datos viejos hasta que el TTL
expirara solo -- reportado por el usuario: ediciones manuales de imagen
no se reflejaban en el storefront, pero la importacion por CSV (mucho
mas lenta) si, porque el TTL tenia tiempo de sobra para expirar en
ambos pods.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:50:16 +00:00
gitea-actions 164223e844 chore(gitops): deploy Docs Portal v1.0.269 [skip ci] 2026-09-20 16:24:54 +00:00
devopsandClaude Sonnet 5 ee327b420c Merge branch 'docs/adr-0010-bugs-imagenes-2026-09-20'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m35s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:17:55 +00:00
devopsandClaude Sonnet 5 17348e6285 docs(adr-0010): agregar dos bugs reportados al checklist de staging
Importacion manual de imagenes de producto no se refleja en el
storefront (CSV si funciona), y en mobile la visualizacion de imagenes
de producto se corta obligando a scrollear. Ambos reportados por el
usuario 2026-09-20, en investigacion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 16:17:47 +00:00
gitea-actions 6c6303cf8e chore(gitops): deploy Docs Portal v1.0.268 [skip ci] 2026-09-20 16:03:40 +00:00
devopsandClaude Sonnet 5 410975f972 Merge branch 'docs/tmux-host-remote-control-continue'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m10s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QMP7ULbxbsB5g1vELNAT3D
2026-09-20 15:57:01 +00:00
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
devopsandClaude Sonnet 5 c507da8abc feat(fulfillment): integrar EnvíoClick para cálculo real de envío
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.

Investigación en vivo (no solo documentación -- misma disciplina que
ya costó varios rehechos con las pasarelas de pago): la SPA de docs
públicas (apidoc.envioclickpro.com.co) no renderiza sin sesión, pero
está generada con apiDoc, así que se bajó y leyó directo su
api_data.json/api_project.json (spec completa real), y se probó
/quotation en vivo con la API key real del usuario -- 200 OK con
tarifas reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. Esto corrigió
varios datos mal asumidos de un plugin de WordPress desactualizado
usado como referencia inicial (API v1): la URL base real es
api.envioclickpro.com.co/api/v2 (no api.envioclickpro.com a secas), y
el header de auth real es "Authorization" a secas, no
"AuthorizationKey".

Hallazgo crítico evitado leyendo el código fuente de
@medusajs/utils/dist/common/define-config.js: defineConfig() ya
registra por defecto un módulo de fulfillment con el provider
manual_manual, aunque medusa-config.ts no lo mencione explícitamente
-- pero transformModules() indexa por serviceName y el último que
llega pisa al anterior (no mezcla providers). Agregar un bloque propio
de fulfillment sin incluir también manual_manual ahí habría roto en
silencio el envío manual que ya usa la shipping option existente.
Verificado cargando la config compilada real: ambos providers quedan
registrados juntos.

- src/modules/envioclick-fulfillment/: AbstractFulfillmentProviderService.
  calculatePrice() cotiza en vivo durante el checkout -- Medusa advierte
  explícitamente que un error acá bloquea el checkout completo, así que
  NUNCA se deja propagar una excepción: cualquier falla (red, dirección
  sin id DIVIPOLA, EnvíoClick caído) cae al precio fijo de $15.000 que
  ya se usaba, para no romper la compra. createFulfillment() vuelve a
  cotizar en el momento de generar la guía real en vez de reusar el
  idRate mostrado en checkout (más correcto: CalculatedShippingOptionPrice
  no tiene forma de persistir ese id entre llamadas, confirmado leyendo
  el tipo real, y un idRate viejo podría estar vencido si el fulfillment
  pasa horas/días después). cancelFulfillment() sí cancela de verdad
  (cancellation/batch/order existe y está confirmado, a diferencia del
  refund de ePayco que quedó sin implementar).
- Solo 1 paquete por cotización/guía (límite real de la API, confirmado:
  "min 1, max 1") -- el carrito se agrega a un solo paquete. Como hoy
  NINGÚN producto tiene peso/dimensiones cargados (confirmado en vivo:
  18 productos activos, 0 con weight), se usa un paquete de fallback
  conservador completo en vez de mezclar datos reales con inventados a
  medias -- la cotización no va a ser precisa hasta que se carguen esos
  datos (tarea de datos, no de código, fuera de alcance de este commit).
- Frontend: cambio chico pero necesario en checkout-client.tsx/
  medusa-cart.ts -- el checkout mandaba el nombre de la ciudad a Medusa,
  nunca el id DIVIPOLA (aunque el form sí lo tenía disponible). Hay
  municipios con el mismo nombre en distintos departamentos de Colombia,
  así que el nombre solo no alcanza para identificar la ciudad sin
  ambigüedad -- se guarda el id real en la metadata de la dirección
  (Medusa sí tiene ese campo), que el fulfillment provider lee para
  derivar el código DANE real que pide EnvíoClick (5 dígitos + "000" de
  relleno, confirmado contra su propio ejemplo: "11001000" = Bogotá).

Pendiente de verificar con una llamada real (no se quiso generar una
guía de prueba en la cuenta real del usuario sin avisar): /shipment_sandbox
y cancellation/batch/order, verificados contra la spec oficial real
pero no probados en vivo todavía.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-08 21:11:43 +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
devops 4992da394b Merge fix/epayco-package-lock into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m24s
2026-09-07 04:17:03 +00:00
devopsandClaude Sonnet 5 f2e0c02013 fix(commerce-backend): regenerar package-lock.json con epayco-sdk-node
El commit anterior agregó epayco-sdk-node a package.json pero el
lockfile nunca se actualizó (se generó localmente con
--package-lock=false para evitar un problema no relacionado del
registry en este entorno) -- npm ci en el Dockerfile exige que el
lockfile esté sincronizado con package.json y rompió el build en CI.
Regenerado con npm install normal; confirmado que npm ci (el comando
real del Dockerfile) ahora sí instala limpio.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 04:16:52 +00:00
devops d4716382d3 Merge fix/epayco-pse-reemplaza-payu into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 30s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 18m42s
2026-09-07 03:58:44 +00:00
devopsandClaude Sonnet 5 12c4afa09c fix(payments): reemplazar PayU LatAm por ePayco como pasarela PSE
PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.

- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
  contrato AbstractPaymentProvider (initiatePayment/authorizePayment
  "pending_authorization"/getWebhookActionAndData con firma SHA256
  verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
  llamar la API cruda -- leyendo el código fuente del SDK (no la
  documentación pública, que resultó desactualizada en más de un campo)
  se confirmó que el protocolo real de ePayco para PSE es bastante más
  particular de lo que sugieren los ejemplos: cada campo del payload se
  cifra con AES usando la private key del comercio, más un login previo
  que da un bearer token que también viaja en la misma request.
  Reimplementar esto a mano tenía mucho más riesgo de error que delegar
  en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
  `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
  búsqueda, pero no existe en el SDK real -- confirmado leyendo
  node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
  campo real para la URL de confirmación es `method_confirmation`
  (con guión bajo en medio), no `metodoconfirmacion` como mostraba la
  documentación pública -- confirmado contra tests/bank.js del propio
  paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
  ePayco documentado); refundPayment sigue sin implementar -- ese
  endpoint no quedó verificado en vivo, tira error explícito en vez de
  fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
  persona + documento (a diferencia del diseño que se había planeado
  para Rapyd, donde esos datos se recolectaban en la página del propio
  Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
  como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
  el código con qué hacer si fallan): el nombre exacto del campo con la
  URL del banco en la respuesta de bank.create() (initiatePayment tira
  un error con el payload completo si no matchea ningún candidato
  probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
  API keys o valores aparte del dashboard de ePayco.

Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 03:58:33 +00:00
gitea-actions f0b4245676 chore(gitops): deploy Medusa v1.0.229 [skip ci] 2026-09-07 02:24:57 +00:00
devops e9e78e64c1 chore: release v1.0.230 [skip ci] 2026-09-07 02:02:25 +00:00
devops 95cf791737 Merge feat/payu-pse-pago into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m42s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m56s
2026-09-07 01:18:06 +00:00
devopsandClaude Sonnet 5 2ceea23f40 feat(payments): integrar PayU LatAm (PSE) como pasarela de pagos
ADR 0009 ya había decidido PayU LatAm como proveedor (única opción que
no exige cuenta Bancolombia/Nequi) y confirmado que no existe ningún
plugin real de Medusa para su API -- los paquetes que aparecen
buscando "PayU" son para PayU Polonia/CEE, API completamente distinta
(clientId/posId/secondKey vs. apiLogin/apiKey/merchantId/accountId).
Este commit implementa esa decisión: módulo de pago custom
(AbstractPaymentProvider) para PSE (transferencia bancaria), primer
alcance del método de pago -- Nequi/Daviplata quedan fuera por ahora.

Backend (commerce-backend):
- src/modules/payu-pse/: initiatePayment arma y postea SUBMIT_TRANSACTION
  contra la API real de PayU LatAm; authorizePayment devuelve
  "pending_authorization" (PSE es async, la confirmación real llega por
  webhook, no en el momento de completar el carrito); getWebhookActionAndData
  valida la firma MD5 del webhook antes de confiar en cualquier
  confirmación de pago -- falla cerrado si no matchea. refundPayment/
  retrievePayment tiran error explícito en vez de simular éxito: esos
  endpoints de PayU no quedaron verificados en vivo y hay dinero real de
  por medio.
- No hace falta un subscriber propio para completar la orden -- el
  subscriber nativo de Medusa (payment-webhook + process-payment-workflow)
  ya hace ese trabajo con los métodos de arriba bien implementados
  (confirmado leyendo el código fuente instalado de @medusajs/medusa
  2.18.0, no la documentación).
- Correlación webhook↔sesión: Medusa inyecta su propio session_id en
  `data` antes de initiatePayment; se usa ese mismo valor como
  referenceCode de PayU, así el reference_sale que vuelve en el webhook
  ya es directamente el session_id de Medusa.
- src/lib/payu.ts: cliente HTTP + firmas (orden y webhook, fórmulas
  distintas, confirmadas contra el SDK PHP oficial de PayU).
- src/api/store/payments/pse-banks/route.ts: lista de bancos PSE,
  cacheada en memoria.
- medusa-config.ts, Dockerfile, configmap/secrets.template: 7 env vars
  nuevas (PAYU_API_LOGIN/API_KEY/MERCHANT_ID/ACCOUNT_ID/ENV/RESPONSE_URL/
  NOTIFY_URL), dummies de build agregados, secrets con placeholder
  "CAMBIAR".

Frontend (ecommerce):
- Nuevo step "payment" en components/checkout/checkout-client.tsx
  (reemplaza al step "review" que solo ofrecía WhatsApp): selector de
  banco, tipo de persona (N/J), tipo/número de documento, botón "Pagar
  con PSE" que crea la sesión de pago, completa el carrito y redirige
  al banco (navegación completa, no iframe -- así lo exige PayU).
  WhatsApp queda como respaldo manual si falla la iniciación del pago.
- lib/headless/providers/medusa-payment.ts + 3 rutas proxy nuevas bajo
  app/api/cart/, mismo patrón que el resto del carrito
  (medusaCartFetch, ahora exportado desde medusa-cart.ts).
- app/checkout/pse/return/: página de vuelta del banco -- nunca afirma
  éxito, la confirmación real es el webhook async.

Arranca contra las credenciales de sandbox genéricas y públicas de
PayU (developers.payulatam.com) mientras el usuario termina su propio
registro de comercio -- swap de las 4 credenciales via secret cuando
estén listas, sin cambios de código.

Verificado: build real de Medusa con las env vars dummy (no solo tsc),
tipos de AbstractPaymentProvider/WebhookActionResult/PaymentActions
confirmados contra @medusajs/utils y @medusajs/types instalados
(node_modules limpiado después), typecheck + lint + build completo del
frontend, páginas nuevas renderizando sin error. Pendiente: prueba
end-to-end contra el sandbox real de PayU (necesita el backend
desplegado) y habilitar el provider en la región Colombia desde el
Admin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 01:17:22 +00:00
devops 4d1804eeeb chore: release v1.0.228 [skip ci] 2026-09-06 21:46:27 +00:00
gitea-actions 9b8b96fd02 chore(gitops): deploy Medusa v1.0.227 [skip ci] 2026-09-06 21:31:24 +00:00
devops 313bb2eab4 Merge fix/navbar-departamentos-hover-gap into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 27s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 26m6s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m54s
2026-09-06 21:04:24 +00:00
devops 710da06c0a Merge fix/catalog-sidebar-scroll into main 2026-09-06 21:04:23 +00:00
devops 9d08169092 Merge fix/product-import-dedupe-por-titulo into main 2026-09-06 21:04:23 +00:00
devopsandClaude Sonnet 5 1aa7802a61 fix(navbar): el menú Departamentos no se cierra al mover el cursor hacia él
El panel del mega-menu tenía mt-1 respecto al botón -- como es
position:absolute no suma a la altura del contenedor que escucha
mouseenter/mouseleave, así que ese margen quedaba como una franja
muerta: mover el cursor del botón hacia el panel disparaba
mouseleave a mitad de camino y cerraba el menú antes de poder hacer
clic en una categoría (reportado en vivo).

Se elimina el gap (mt-1) y se agrega un delay de 200ms antes de cerrar,
cancelable si el cursor vuelve a entrar, como margen extra para
movimientos rápidos o en diagonal cerca del borde.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-06 21:04:16 +00:00
devopsandClaude Sonnet 5 b83722e9c5 fix(catalog): scroll propio en el sidebar de filtros
El <aside> de Filtros es sticky pero sin altura máxima ni overflow
propio -- con 30+ categorías más marca/edad/precio, el panel se pasa
del viewport y queda inalcanzable (reportado en vivo: había que bajar
el zoom del navegador para ver "Marca"/"Edad"/"Precio").

overflow-y-auto + max-h acotado al viewport (solo en lg, que es donde
aplica sticky) le da scroll propio al panel sin depender del scroll de
la página.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-06 21:04:04 +00:00
devopsandClaude Sonnet 5 8d543f5eb1 fix(product-import): no duplicar producto al re-importar el mismo título
Re-subir la misma fila de CSV (ej. para agregar más fotos o actualizar
la descripción de un producto ya cargado) creaba un producto nuevo con
handle distinto -- confirmado en vivo re-importando "Hello Kitty &
Friends" para sumar fotos: quedaron dos productos con el mismo título
en la tienda.

Ahora, si el título coincide (case-insensitive/trim) con un producto ya
importado, se actualiza ese producto en vez de crear uno: las fotos
nuevas se agregan a las que ya tenía (no se reemplazan) y la
descripción se actualiza si la fila trae una. No toca
precio/cantidad/categoría/colección del producto existente.

El duplicado ya creado se fusionó en vivo (fotos movidas al original,
duplicado borrado) sin esperar este deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-06 21:03:56 +00:00
devops df791e0c28 chore: release v1.0.226 [skip ci] 2026-09-06 19:55:30 +00:00
gitea-actions feda1283d4 chore(gitops): deploy Medusa v1.0.225 [skip ci] 2026-09-06 19:46:05 +00:00
devops 8fd0514e76 Merge feat/product-gallery-hover-zoom into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m54s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m11s
2026-09-06 19:19:19 +00:00
devops 1cde29b344 Merge fix/product-import-crear-categoria-coleccion into main 2026-09-06 19:19:18 +00:00
devopsandClaude Sonnet 5 8517102b63 feat(product-gallery): zoom automático de la imagen al pasar el cursor
Antes el único zoom era el modal interactivo (click -> Dialog con scale/
drag). Ahora la imagen principal también hace zoom estilo lupa (2.2x)
que sigue la posición del cursor con mousemove/transform-origin, sin
tocar el modal existente (sigue abriéndose igual con el click).

Solo mouse -- mousemove no dispara de forma continua en touch, así que
en mobile simplemente no aparece el efecto, sin romper nada ahí.

Verificado: tsc --noEmit limpio, página de producto renderiza 200 con
datos mock y el nuevo markup presente. No se pudo verificar visualmente
el hover en un navegador real (sin chromium-cli ni claude-in-chrome
disponibles en este shell headless).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-06 19:18:11 +00:00
devopsandClaude Sonnet 5 7773bcdecf fix(product-import): crear categoría/colección si no existe en vez de descartarla en silencio
El importador solo enlazaba categoría/colección cuando el nombre del CSV
matcheaba exacto (case-insensitive) una ya existente -- si no había match,
category_ids/collection_id quedaban undefined sin ningún error. Así quedó
el producto "Hello Kitty & Friends" importado sin colección: la colección
nunca existió en la tienda, el importador nunca la creó ni avisó.

Ahora, si el nombre no matchea, se crea la categoría/colección nueva
(createProductCategoriesWorkflow / createCollectionsWorkflow) con el mismo
slugify() ASCII-safe que ya usa el handle del producto, por la misma razón:
el default de Medusa no quita tildes/eñes y rompe la ruta pública.

El producto ya importado se corrigió en caliente vía Admin API (colección
creada + enlazada + handle corregido de "hello-kitty-&-friends" a
"hello-kitty-friends"), sin esperar este deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-06 19:17:47 +00:00
gitea-actions c62ad626cc chore(gitops): deploy Docs Portal v1.0.224 [skip ci] 2026-09-06 17:27:30 +00:00
devopsandClaude Sonnet 5 8835d8ca0c docs(tmux-host): documentar Dockerfile + healthcheck cada 30 min
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m57s
Actualiza sesion-tmux-persistente.md: la imagen de tmux-host ahora se
construye desde scripts/tmux-host/Dockerfile (WORKDIR fijo en la raiz
del lab) en vez del docker run inline anterior, y se agrega la seccion
del chequeo de salud en background (red + sesion tmux, cada 30 min,
notificacion via el mismo canal de Telegram del lab-agent). Actualiza
tambien la tabla "Que sobrevive a que" para reflejar la auto-recuperacion
de la sesion tmux.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012xyPak6HWFmBCqXrvFsejL
2026-09-06 17:18:10 +00:00
gitea-actions 997a00c284 chore(gitops): deploy Medusa v1.0.223 [skip ci] 2026-09-06 17:16:31 +00:00
devopsandClaude Sonnet 5 0fb904b9ac Merge fix/adr0006-import-handle-ascii-safe into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 40s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 29m54s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:43:55 +00:00
devopsandClaude Sonnet 5 a5ede05d96 fix(product-import): generar handle ASCII-safe en vez del default de Medusa
3 productos importados con título acentuado (ñ/tildes) quedaron con handle
no-ASCII y daban 404 real al entrar por shop.cruzcloud.net -- el tunel/
ingress público rompe rutas con UTF-8 no-ASCII en el path, confirmado en
vivo comparando el mismo request directo al servicio (200 OK) contra el
dominio público (404). Se genera el handle explícito (slugify propio,
sin dependencia nueva) con sufijo único por fila para evitar colisiones
entre títulos parecidos. Los 3 productos ya publicados con handle roto
se corrigieron en caliente vía Admin API, sin esperar este deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:43:47 +00:00
devopsandClaude Sonnet 5 63746eb090 Merge origin/main (chore: deploy Medusa v1.0.222) into local main
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:58 +00:00
devopsandClaude Sonnet 5 2ca00d032d Merge fix/adr0008-doc-estado-final-palette-amendment into main
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:43 +00:00
devopsandClaude Sonnet 5 cb15749448 docs(adr-0008): registrar pivote de paleta azul/amarillo y estado real por sub-fase
La spec original (Navy #122844/Coral #D85A30) fue reemplazada en producción
el mismo día por una paleta azul/amarillo estilo Walmart
(fix/adr0008-walmart-palette-blue-yellow), pero nunca quedó documentada
como enmienda. Se agrega §8 con los valores reales vigentes en globals.css
y §9 con auditoría por sub-fase contra el código en main: sub-fases 1-4
completas, sub-fase 5 parcial (product-purchase migrado, cart-drawer y
wishlist-drawer sin tocar, alias de compatibilidad de button.tsx sin
eliminar).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:36 +00:00
gitea-actions 9e1adf4627 chore(gitops): deploy Medusa v1.0.222 [skip ci] 2026-09-04 14:18:07 +00:00
devops 0ad74b6b70 Merge branch 'chore/product-import-flat-bucket-note' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m5s
2026-09-04 13:54:31 +00:00
devops d1d007fc7d chore(commerce-backend): corregir comentario sobre carpeta de fotos importadas
El S3 file provider de Medusa descarta cualquier subcarpeta del
filename (usa solo path.parse().name + ulid, confirmado leyendo
@medusajs/file-s3/dist/services/s3-file.js) -- no existe una carpeta
import-products/ separada como decia el comentario original. El bucket
(ari-products) es plano, igual para todas las fotos del catalogo.
Confirmado en vivo con una importacion real de 3 productos.
2026-09-04 13:54:23 +00:00
gitea-actions 11ac6d7949 chore(gitops): deploy Medusa v1.0.221 [skip ci] 2026-09-04 05:27:25 +00:00
devops 093ae419d5 Merge branch 'fix/product-import-category-select' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 16m51s
2026-09-04 04:35:28 +00:00
devops 2b0d50c3d0 fix(commerce-backend): traer name/title explicito en el importador
listProductCategories({})/listProductCollections({}) sin select
explicito devolvian name/title undefined (a diferencia de la capa HTTP
del Admin API, que si los trae completos por defecto) -- causaba un 500
"Cannot read properties of undefined (reading 'toLowerCase')" en
cualquier import real. Confirmado en vivo con una prueba de importacion
de 3 productos antes de este fix.

De paso, se agrega un filtro defensivo antes del .map() para que una
categoria/coleccion sin nombre no tumbe el import entero.
2026-09-04 04:35:20 +00:00
gitea-actions 8126bb3d81 chore(gitops): deploy Medusa v1.0.220 [skip ci] 2026-09-02 21:53:50 +00:00
devops dedf4f0963 Merge branch 'feat/product-import' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 59s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 26m14s
2026-09-01 03:47:43 +00:00
devops 1cd1078cdf feat(commerce-backend): importador masivo de productos via CSV
Nueva pagina "Importacion" en Medusa Admin para cargar varios productos
de una sola vez (comprados en Amazon/eBay/etc, no dropshipping
automatizado -- se evaluo y descarto raspar catalogos de terceros sin
autorizacion por riesgo de copyright/ToS).

src/api/admin/product-import/route.ts: GET sirve la plantilla CSV
descargable, POST procesa fila por fila (una foto rota o categoria mal
escrita no tumba el resto del archivo). Cada foto se descarga del origen
y se re-aloja en el mismo MinIO/S3 del NAS que ya usa el catalogo
(prefijo import-products/, separado de ari-products/), se crea el
registro de inventario con la cantidad indicada y el producto queda
vinculado con el mismo patron "Presentacion"/"Estandar" que usa el resto
del catalogo -- el storefront no necesita cambios.

src/lib/csv.ts: parser CSV propio (RFC4180), sin sumar dependencia nueva.

Workflows y servicios de modulo (uploadFilesWorkflow,
createInventoryItemsWorkflow, createProductsWorkflow, stock-location/
fulfillment/sales-channel/product module services) verificados contra
los .d.ts reales instalados, no solo contra documentacion.
2026-09-01 03:47:32 +00:00
devops 601378a77e chore: release v1.0.219 [skip ci] 2026-08-29 20:31:52 +00:00
devops 1dccd0aa6a Merge branch 'feat/out-of-stock-ui' into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m30s
2026-08-29 20:23:02 +00:00
devops 474ac3795b fix(ecommerce): deshabilitar compra y mostrar "Agotado" sin stock real
variant.available (calculado en lib/headless/providers/medusa.ts a
partir de manage_inventory/allow_backorder/inventory_quantity) ya
llegaba correcto hasta el componente, pero ningun boton de compra lo
usaba -- se podia seguir agregando al carrito un producto con stock 0.

Afecta product-purchase.tsx (pagina de producto), mobile-add-to-cart.tsx
(barra fija mobile) y product-card.tsx (tarjetas de catalogo): boton
deshabilitado + label "Agotado" en los tres, mas badge/overlay en la
tarjeta de catalogo.
2026-08-29 20:22:55 +00:00
gitea-actions bb2ad3c317 chore(gitops): deploy Medusa v1.0.218 [skip ci] 2026-08-29 19:19:15 +00:00
devops 578f085334 Merge branch 'fix/inventory-panel-ts-error' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 18m56s
2026-08-29 18:59:55 +00:00
devops ce4842f375 fix(commerce-backend): corregir tipo de callback en location_levels.find
TS2769: la anotacion explicita {location_id: string} no calzaba con el
tipo Maybe<InventoryLevel> que infiere query.graph para location_levels.
Se quita la anotacion y se deja inferir, con optional chaining.
2026-08-29 18:59:48 +00:00
devops 2cce671049 Merge branch 'feat/inventory-panel' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 32s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m52s
2026-08-29 17:56:51 +00:00
devops 22f97ee12e feat(commerce-backend): panel simplificado de inventario en Medusa Admin
Nueva pestana "Inventario" en el sidebar del Admin (src/admin/routes/
inventario), pensada para un usuario no tecnico: una fila por producto
(catalogo real confirmado 1 variante por producto), switch para activar
seguimiento de stock, input de cantidad que guarda solo, badge de stock
bajo (<=5 unidades) y activacion/desactivacion en bloque por seleccion.
No reemplaza la vista nativa de Inventory de Medusa, es un atajo.

Backend: src/api/admin/inventory-panel/route.ts + workflow propio
src/workflows/attach-inventory-item-to-variant.ts para vincular un
InventoryItem nuevo a una variante existente (Medusa no lo hace solo al
togglear manage_inventory en un producto ya creado -- confirmado en vivo
contra un producto [DEMO] antes de escribir el codigo).

Nombres de campos y workflows verificados contra los .d.ts reales
instalados en el pod (no solo contra la documentacion, que no cubre bien
este flujo de vincular inventario a una variante existente).
2026-08-29 17:56:44 +00:00
devops 42c90d8534 chore: release v1.0.216 [skip ci] 2026-08-29 17:10:35 +00:00
devops cd8d2edd8f Merge branch 'feat/google-oauth-frontend' into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m39s
2026-08-29 17:01:39 +00:00
devops 77389aea19 feat(ecommerce): login con Google para clientes
Implementa el flujo completo de OAuth social de Medusa para el
storefront: boton "Continuar con Google" en account-dialog, ruta proxy
que arranca el flujo (GET /api/customer/google/start) y pagina de
callback en /auth/google/callback (coincide con GOOGLE_CALLBACK_URL del
secret) que resuelve el code/state contra el backend, decodifica el JWT
para distinguir cliente nuevo vs existente, crea el customer si hace
falta y refresca el token a sesion final -- mismo patron de
store/customer-store.ts que login/register por email.

Flujo verificado en vivo contra el backend ya desplegado (GET
/auth/customer/google, /auth/customer/google/callback con state
invalido, /auth/token/refresh con bearer invalido) antes de escribir el
codigo, no solo contra la documentacion.
2026-08-29 17:01:32 +00:00
gitea-actions 7ce11ecbb4 chore(gitops): deploy Medusa v1.0.215 [skip ci] 2026-08-29 16:34:34 +00:00
devops da10a4b688 Merge branch 'fix/google-oauth-build-env' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 21m48s
2026-08-29 16:12:33 +00:00
devops 0b7dc7b6de fix(commerce-backend): agregar dummies de build para vars de Google OAuth
npm run build (medusa build) carga medusa-config.ts, que ahora exige
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALLBACK_URL via required().
Sin valores dummy en el builder stage, el build fallaba con "Error in
loading config: La variable GOOGLE_CLIENT_ID es obligatoria" -- mismo
patron ya usado para JWT_SECRET/MEILISEARCH_API_KEY.
2026-08-29 16:12:27 +00:00
devops 2601e5e83e Merge branch 'feat/google-oauth-backend' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m25s
2026-08-29 16:03:55 +00:00
devops fecac320d9 feat(commerce-backend): habilitar login con Google via Medusa auth module
Agrega el modulo @medusajs/medusa/auth con providers emailpass (explicito,
para no perder el default al customizar el modulo) y google, leyendo
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_CALLBACK_URL desde
commerce-secrets con required().
2026-08-29 16:03:40 +00:00
devops 811e0e0887 chore: release v1.0.213 [skip ci] 2026-08-29 15:24:19 +00:00
devops aee6515890 Merge feat/cuenta-icono-mobile-indicador into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m58s
2026-08-29 15:11:02 +00:00
devops 2e47f7b55d feat(ecommerce): icono de cuenta visible en mobile + indicador de sesion
Pedido explicito del usuario tras probar el login real: el boton de
'Mi cuenta' estaba oculto en mobile (hidden sm:inline-flex, solo
alcanzable desde el menu hamburguesa). Pasa a estar siempre visible en
el header, y agrega un punto verde cuando hay sesion iniciada -- mismo
patron visual que los badges de cantidad de carrito/favoritos, pero de
presencia (con sesion o sin ella), no de conteo.
2026-08-29 15:10:59 +00:00
devops 54be99fa2c chore: release v1.0.212 [skip ci] 2026-08-29 14:33:18 +00:00
devops bc530c98e6 Merge feat/auth-clientes-real into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m15s
2026-08-29 14:24:45 +00:00
devops 3b748cc014 feat(ecommerce): cuentas de cliente reales (registro + login)
Conecta el dialogo 'Tu cuenta ARI' (antes solo ofrecia WhatsApp) al
Store API real de Medusa. Flujo verificado en vivo con un cliente de
prueba real antes de escribir el codigo: registro -> crear customer
-> login -> /store/customers/me, todo confirmado funcionando.

Detalle no documentado que se confirmo en vivo: el token que devuelve
/auth/customer/emailpass/register sirve para crear el customer via
POST /store/customers, pero no queda como sesion real -- hay que
loguear de nuevo (/auth/customer/emailpass) para obtener el token de
sesion. Mensajes de error tambien verificados: correo repetido -> 401
'Identity with email already exists'; credenciales invalidas -> 401
'Invalid email or password'.

Mismo patron arquitectonico que el carrito real (ADR 0009): rutas
puente /api/customer/* (MEDUSA_BACKEND_URL es DNS interno, no resuelve
desde el navegador), store de Zustand con sesion persistida y
resincronizada contra el servidor al rehidratar. WhatsApp se mantiene
como opcion secundaria, ya no es la unica salida.
2026-08-29 14:24:43 +00:00
devops 73f9424331 chore: release v1.0.211 [skip ci] 2026-08-29 05:23:55 +00:00
devops 0917c966cb Merge feat/checkout-departamentos-ciudades into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m19s
2026-08-29 05:16:20 +00:00
devops 61024b8f31 feat(ecommerce): selector Departamento/Ciudad con DIVIPOLA real en checkout
Reemplaza los inputs de texto libre de Ciudad/Departamento por dos
selects en cascada, poblados con el dataset oficial de DIVIPOLA (33
departamentos + 1123 municipios, fuente proyecto26/colombia,
verificado contra la codificacion real del DANE antes de incorporarlo
-- id 5001 = Medellin, departmentId 5 = Antioquia, etc.). Sin
dependencia npm nueva (JSON estatico + helper propio).

Se guarda el id DIVIPOLA seleccionado en el estado del formulario, no
solo el nombre -- EnvioClick (la integracion de envios real que
reemplazara la tarifa fija de este ADR) va a necesitar identificar la
ciudad sin ambiguedad, y las transportadoras colombianas referencian
ciudades por este codigo. El nombre derivado del id sigue siendo lo
que se manda a Medusa (shipping_address espera texto, no cambia el
contrato existente).
2026-08-29 05:16:18 +00:00
devops 7dc54a1d20 chore: release v1.0.210 [skip ci] 2026-08-29 04:20:02 +00:00
devops d05112ae65 Merge feat/checkout-direccion-envio into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m30s
2026-08-29 04:12:13 +00:00
devops bb2c0bbe85 feat(ecommerce): ADR 0009 - checkout con direccion y seleccion de envio
Siguiente corte tras el carrito real: pagina /checkout de 3 pasos
(direccion -> envio -> confirmar) contra el Store API real, usando la
shipping option que ya se verifico en produccion. Endpoints nuevos
(setShippingAddress, getShippingOptions, addShippingMethod) probados
en vivo con un carrito de prueba real antes de escribir el codigo --
direccion, opciones de envio y total ya confirmados correctos
(80.000 + 5.000 = 95.000).

El pago (PayU custom) sigue sin existir, asi que el ultimo paso sigue
siendo WhatsApp -- pero ahora el mensaje incluye direccion, metodo de
envio y total real en vez de solo la lista de productos. El boton del
CartDrawer pasa de abrir WhatsApp directo a llevar a /checkout.
2026-08-29 04:12:11 +00:00
devops cdab538cfb chore: release v1.0.209 [skip ci] 2026-08-29 03:53:43 +00:00
devops f124ae0bbd Merge fix/carrito-thumbnail-real into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 11m47s
2026-08-29 03:24:11 +00:00
devops b8ce9ded87 fix(ecommerce): carrito muestra la foto real del producto
Reportado en produccion: el carrito mostraba el logo generico de ARI
en vez de la foto del producto. Verificado en vivo: item.thumbnail
viene null en la respuesta de Medusa para todo el catalogo real
(confirmado tambien contra /store/products directamente -- el campo
thumbnail del producto es null en la fuente, no solo omitido en la
respuesta del carrito). El catalogo ya resuelve esto cayendo a
images[0] (mapProduct() en medusa.ts); la linea del carrito necesita
pedir esa relacion explicita via fields=*items.product.images (no
viene por defecto) y aplicar el mismo fallback.
2026-08-29 03:24:09 +00:00
devops b12dd848af chore: release v1.0.208 [skip ci] 2026-08-29 02:52:32 +00:00
devops d28ee4a49d Merge fix/checkout-carrito-real-medusa into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m42s
2026-08-29 02:42:32 +00:00
devops 3fc77c6cdc feat(ecommerce): ADR 0009 - carrito real conectado a Medusa (Store API)
Primer corte de checkout real, a proposito acotado: el carrito pasa de
estado 100% local (localStorage con productos completos) a sincronizar
con un cart_id real de Medusa via /api/cart/* (rutas puente
same-origin, mismo patron que /api/search -- MEDUSA_BACKEND_URL es DNS
interno del cluster, no resuelve desde el navegador).

Formas de respuesta de la API verificadas en vivo con un carrito de
prueba real antes de escribir el mapeo (no asumidas contra
documentacion): agregar el mismo variant_id dos veces hace merge de
cantidad automatico (no crea linea duplicada); eliminar una linea
devuelve el carrito actualizado en 'parent', no en 'cart' como los
demas endpoints.

La firma publica de useCartStore (addItem/removeItem/updateQuantity/
items) no cambia, asi que ningun componente consumidor (cart-drawer,
navbar, product-card, product-purchase, mobile-add-to-cart) necesito
tocarse. Sigue cerrando por WhatsApp -- direccion/envio/pago quedan
para los siguientes cortes del ADR 0009.
2026-08-29 02:42:29 +00:00
gitea-actions 14c473eb8b chore(gitops): deploy Docs Portal v1.0.207 [skip ci] 2026-08-29 01:29:23 +00:00
devops 93e2401e3b Merge docs/adr0009-correccion-plugin-payu into main
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 6m9s
2026-08-29 01:23:17 +00:00
devops 615cf9ce77 docs(decisiones): ADR 0009 - correccion, no hay plugin real para PayU LatAm
Verificado el codigo/README real de los dos unicos paquetes de Medusa
que aparecen buscando 'PayU' (@tax1driver/medusa-payu y
medusa-plugin-payu de rigby-sh, este ultimo archivado desde
2025-03-21): ambos usan la terminologia de la API de PayU Polonia/CEE
(clientId/posId/secondKey, OAuth), no la de PayU LatAm
(apiLogin/apiKey/accountId, developers.payulatam.com, la que cubre
Colombia). 'PayU' es una marca compartida por dos productos distintos.

La decision de negocio (PayU como proveedor) se mantiene -- cambia la
via tecnica: integracion via modulo de pago custom
(AbstractPaymentProvider de Medusa) contra la API real de PayU LatAm,
mismo tipo de esfuerzo que EnvioClick, no instalacion de plugin.

De paso, documenta las shipping options ya creadas y verificadas en
produccion (stock location, fulfillment set, service zone Colombia,
shipping option de 5.000 COP) incluyendo el paso no documentado que
las dejaba invisibles para el Store API (vinculo a sales channel).
2026-08-29 01:23:15 +00:00
gitea-actions 5931e8933c chore(gitops): deploy Docs Portal v1.0.206 [skip ci] 2026-08-28 23:24:57 +00:00
devops 52c628faa0 Merge docs/adr0009-payu-decidido into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m19s
2026-08-28 23:19:32 +00:00
devops f88b72d781 docs(decisiones): ADR 0009 - PayU elegido como proveedor de pago
Dato de negocio real del usuario: cuentas disponibles son Davivienda,
Lulo Bank y Banco de Bogota -- ninguna Bancolombia ni Nequi.

Verificado el mismo dia: Wompi exige cuenta Bancolombia o al menos
Nequi solo para REGISTRARSE como comercio (aunque la liquidacion
despues pueda ir a cualquier banco) -- ninguno de los bancos
disponibles califica sin abrir una cuenta nueva. PayU solo exige
cuenta bancaria colombiana a nombre del comercio, sin banco
especifico, y ya soporta PSE + Nequi + Daviplata para el cliente sin
importar el banco del comercio -- cubre exactamente lo que se pidio
(PSE + Nequi 'y demas bancos').

Estado pasa de propuesta a aceptada. Wompi se deja documentado como
deuda a reevaluar si en el futuro se abre cuenta Bancolombia/Nequi --
no se descarta por limitacion tecnica del plugin, solo por el
requisito de registro actual.
2026-08-28 23:19:30 +00:00
gitea-actions 525ebde5ce chore(gitops): deploy Docs Portal v1.0.205 [skip ci] 2026-08-28 22:39:13 +00:00
devops 0f98fa286e Merge docs/adr0009-checkout-real into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 17s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m22s
2026-08-28 22:33:39 +00:00
devops 33f510dcdd docs(decisiones): ADR 0009 - checkout real (pago en linea + envios)
Propuesta, no aceptada: investiga el estado real de Medusa 2.18.0 para
soportar checkout de verdad (hoy el carrito cierra por WhatsApp).

Verificado contra codigo real y documentacion oficial de Medusa
(2026-08-28): cero shipping options configuradas (bloqueador
independiente del proveedor de pago), Stripe es de primera clase en
Medusa v2 pero no acepta comercios registrados en Colombia
(confirmado contra stripe.com/global), PayU tiene compatibilidad
declarada con Medusa 2.4.0+, Wompi no documenta version de Medusa ni
actividad reciente.

Deja la eleccion de proveedor de pago explicitamente pendiente de
decision de negocio del usuario -- no es algo que se pueda resolver
en el codigo.
2026-08-28 22:33:37 +00:00
devops 52295e2c6d chore: release v1.0.204 [skip ci] 2026-08-28 21:47:40 +00:00
devops fe0387cc4c Merge fix/adr0008-club-ari-imagen-azul into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m47s
2026-08-28 21:36:37 +00:00
devops cfec1543ab fix(ecommerce): imagen decorativa de Club ARI en azul/amarillo
promo-backpacks.webp tenia el fondo punteado violeta/morado del
esquema arcoiris original, notado al confirmar el fix anterior de
Club ARI (el panel de texto ya paso a azul solido, pero la foto de al
lado seguia con su propio color viejo).

Sin herramienta de generacion de imagenes disponible, se reconstruye
el mismo efecto (fondo punteado + fotos estilo polaroid) con CSS
(radial-gradient azul sobre fondo amarillo, circulo decorativo azul)
y 3 fotos reales de producto ya existentes en public/images/products/
en vez de la foto compuesta vieja.
2026-08-28 21:36:35 +00:00
devops 49a6645378 chore: release v1.0.203 [skip ci] 2026-08-28 21:17:07 +00:00
devops 11ef812f64 Merge fix/adr0008-club-ari-y-novedades into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m14s
2026-08-28 21:09:33 +00:00
88 changed files with 7268 additions and 181 deletions

No files matched your search

+63 -2
View File
@@ -126,8 +126,20 @@ 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.
# Cambio 2026-09-20: la promocion de Staging a Produccion (antes
# scripts/promote-to-production.sh, manual con confirmacion
# interactiva) ahora corre sola en este mismo job -- ver el step
# "Promover a Produccion (CD)" mas abajo, justo despues del de
# Staging. Decision explicita del usuario: cero gate humano entre
# Staging y Produccion. El script manual queda solo como
# herramienta de rollback/excepcion, ya no es el camino normal.
MANIFEST_FILE: workloads/ecommerce-staging/frontend/patches/deployment.yaml
PROD_MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
KUSTOMIZE_DIR: workloads/ecommerce-staging/frontend
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
steps:
@@ -579,6 +591,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
@@ -592,6 +611,48 @@ jobs:
# sobrescribir commits ajenos o promocionar una versión antigua.
git push origin HEAD:main
# Automatiza lo que antes era scripts/promote-to-production.sh (paso
# manual con confirmación interactiva, read -rp). Decisión del
# usuario 2026-09-20: cero pasos manuales entre Staging y
# Producción — la misma imagen que acaba de promocionar a Staging
# arriba se promueve sola a Producción, sin gate humano en el
# medio. Mismo sed/perl que el script manual (mismo MANIFEST, otro
# archivo), corriendo como un segundo commit separado justo
# después del de Staging — no se pisan en un solo commit porque
# Staging y Producción son releases conceptualmente distintos
# (permite revertir uno sin tocar el otro).
- name: Promover a Producción (CD)
if: steps.promotion.outputs.promote == 'true'
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}"
git config user.name "Cristian Felipe"
git config user.email "[email protected]"
# Re-fetch: el step de Staging de arriba ya pusheó un commit a
# main en este mismo job -- sin este refresh, este checkout
# local queda un commit atrás y el push de abajo se rechaza.
git fetch origin main
git checkout -B main origin/main
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${PROD_MANIFEST_FILE}"
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${VERSION}/" "${PROD_MANIFEST_FILE}"
git add "${PROD_MANIFEST_FILE}"
if git diff --cached --quiet; then
echo "Producción ya está en ${VERSION}."
exit 0
fi
git commit -m "chore: promote ${VERSION} to production [skip ci]"
git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
+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
+20 -1
View File
@@ -23,7 +23,26 @@ ENV CI=true \
S3_BUCKET=ari-shopping \
S3_ENDPOINT=http://127.0.0.1:9000 \
MEILISEARCH_HOST=http://127.0.0.1:7700 \
MEILISEARCH_API_KEY=build-only-meili-key
MEILISEARCH_API_KEY=build-only-meili-key \
GOOGLE_CLIENT_ID=build-only-google-client-id \
GOOGLE_CLIENT_SECRET=build-only-google-client-secret \
GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback \
EPAYCO_PUBLIC_KEY=build-only-epayco-public \
EPAYCO_PRIVATE_KEY=build-only-epayco-private \
EPAYCO_P_CUST_ID_CLIENTE=build-only-epayco-pcust \
EPAYCO_P_KEY=build-only-epayco-pkey \
EPAYCO_ENV=sandbox \
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \
ENVIOCLICK_API_KEY=build-only-envioclick-key \
ENVIOCLICK_SANDBOX=true \
ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \
ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \
ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \
ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \
ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \
ENVIOCLICK_ORIGIN_EMAIL=[email protected] \
ENVIOCLICK_ORIGIN_PHONE=3000000000
COPY package.json package-lock.json .npmrc ./
@@ -133,6 +133,45 @@ module.exports = defineConfig({
},
modules: [
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
// directo) -- pero como este bloque propio comparte el mismo tipo de
// modulo, transformModules() lo pisa por completo (indexa por
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
// manual_manual va explicito ACA tambien: si se omitiera, la
// shipping option existente que ya usa ese provider se rompe en
// silencio. envioclick-fulfillment implementa el mismo contrato
// (AbstractFulfillmentProviderService) contra
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
// con una llamada de prueba real que devolvio tarifas reales de
// varias transportadoras.
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "@medusajs/medusa/fulfillment-manual",
id: "manual",
},
{
resolve: "./src/modules/envioclick-fulfillment",
id: "standard",
options: {
apiKey: required("ENVIOCLICK_API_KEY"),
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
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",
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
},
},
],
},
},
{
resolve: "@medusajs/medusa/file",
options: {
@@ -157,6 +196,63 @@ module.exports = defineConfig({
],
},
},
{
resolve: "@medusajs/medusa/auth",
options: {
providers: [
// emailpass se declara explicito junto con google -- una vez
// que el modulo auth se customiza, la doc no deja claro si el
// provider default (emailpass) se sigue registrando solo.
{
resolve: "@medusajs/medusa/auth-emailpass",
id: "emailpass",
options: {},
},
{
resolve: "@medusajs/medusa/auth-google",
id: "google",
options: {
clientId: required("GOOGLE_CLIENT_ID"),
clientSecret: required("GOOGLE_CLIENT_SECRET"),
callbackUrl: required("GOOGLE_CALLBACK_URL"),
},
},
],
},
},
// ADR 0009: proveedor de pago -- tercera vuelta. PayU LatAm fue la
// decision original, pero Rapyd compro ese negocio en 2023 y el
// registro real del usuario quedo con credenciales de Rapyd
// (incompatibles, API completamente distinta). Se evaluo Rapyd en
// vivo y se termino descartando: comision mas alta que otras
// opciones para el volumen real del usuario. ePayco elegido en su
// lugar -- 100% de Davivienda desde 2024, comision PSE mas baja,
// tarifa preferencial con cuenta Davivienda (que el usuario ya
// tiene), sin exigir Bancolombia/Nequi como si exige Wompi. Tampoco
// existe plugin real de Medusa para ePayco, modulo custom propio.
// Alcance inicial: solo PSE, no tarjetas/otros metodos.
// provider_id resultante: "pp_epayco_pse" (identifier "epayco" + id
// "pse", convencion de Medusa).
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/epayco-pse",
id: "pse",
options: {
publicKey: required("EPAYCO_PUBLIC_KEY"),
privateKey: required("EPAYCO_PRIVATE_KEY"),
pCustIdCliente: required("EPAYCO_P_CUST_ID_CLIENTE"),
pKey: required("EPAYCO_P_KEY"),
env: process.env.EPAYCO_ENV || "sandbox",
responseUrl: required("EPAYCO_RESPONSE_URL"),
notifyUrl: required("EPAYCO_NOTIFY_URL"),
},
},
],
},
},
],
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
+213 -1
View File
@@ -13,7 +13,8 @@
"@medusajs/cli": "2.18.0",
"@medusajs/framework": "2.18.0",
"@medusajs/medusa": "2.18.0",
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
"epayco-sdk-node": "^1.4.4"
},
"devDependencies": {
"@swc/core": "^1.7.28",
@@ -9200,6 +9201,15 @@
"node": ">=4.0.0"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -9363,6 +9373,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"license": "MIT"
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
@@ -9372,6 +9388,12 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -9434,6 +9456,13 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -9806,6 +9835,35 @@
"node": ">= 0.8"
}
},
"node_modules/epayco-sdk-node": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/epayco-sdk-node/-/epayco-sdk-node-1.4.4.tgz",
"integrity": "sha512-i6SpQru6Hwp096yxAVaD4lsV76ASB0cqAbJsvXQoc0leCsePHRjetV/9C1AASw8ZFbnkmTajw9DhWsAvjdTEFw==",
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"crypto-js": "^4.2.0",
"debug": "2.6.9",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.0",
"superagent": "^3.7.0"
}
},
"node_modules/epayco-sdk-node/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/epayco-sdk-node/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -10099,6 +10157,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -10292,6 +10356,16 @@
"node": ">= 6"
}
},
"node_modules/formidable": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz",
"integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==",
"deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -11207,6 +11281,12 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -12164,6 +12244,26 @@
"node": ">= 8.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -12924,6 +13024,12 @@
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -14354,6 +14460,90 @@
"node": ">= 6"
}
},
"node_modules/superagent": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
"license": "MIT",
"dependencies": {
"component-emitter": "^1.2.0",
"cookiejar": "^2.1.0",
"debug": "^3.1.0",
"extend": "^3.0.0",
"form-data": "^2.3.1",
"formidable": "^1.2.0",
"methods": "^1.1.1",
"mime": "^1.4.1",
"qs": "^6.5.1",
"readable-stream": "^2.3.5"
},
"engines": {
"node": ">= 4.0"
}
},
"node_modules/superagent/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/superagent/node_modules/form-data": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/superagent/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/superagent/node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/superagent/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/superagent/node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -14635,6 +14825,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@@ -15099,6 +15295,22 @@
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
"@medusajs/cli": "2.18.0",
"@medusajs/framework": "2.18.0",
"@medusajs/medusa": "2.18.0",
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
"epayco-sdk-node": "^1.4.4"
},
"devDependencies": {
"@swc/core": "^1.7.28",
@@ -0,0 +1,9 @@
import Medusa from "@medusajs/js-sdk";
// baseUrl "/" -- esta pagina se sirve dentro del mismo dashboard admin
// (mismo origen que la API), no hace falta apuntar a otra URL. auth
// "session" reusa la cookie de sesion ya autenticada del dashboard.
export const sdk = new Medusa({
baseUrl: "/",
auth: { type: "session" },
});
@@ -0,0 +1,148 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
import { useRef, useState } from "react";
import { sdk } from "../../lib/sdk";
// Importador masivo via CSV -- pensado para cargar varios productos de
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
// automatizado -- ver conversacion sobre por que se descarto raspar
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
type RowResult = {
row: number;
title: string;
ok: boolean;
action?: "created" | "updated";
error?: string;
};
function downloadTemplate() {
window.open("/admin/product-import", "_blank");
}
const ProductImportPanel = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<RowResult[] | null>(null);
async function handleFile(file: File) {
const csv = await file.text();
setLoading(true);
setResults(null);
try {
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
method: "post",
body: { csv },
});
if (data.error) {
toast.error("No se pudo procesar el archivo", { description: data.error });
return;
}
setResults(data.results ?? []);
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
const failCount = (data.results?.length ?? 0) - okCount;
if (failCount === 0) {
toast.success(`${okCount} productos importados`);
} else {
toast.warning(`${okCount} importados, ${failCount} con error`, {
description: "Revisa el detalle abajo.",
});
}
} catch (error) {
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
} finally {
setLoading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading level="h2">Importación de productos</Heading>
<Text size="small" className="text-ui-fg-subtle">
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
</Text>
</div>
<div className="flex gap-2">
<Button size="small" variant="secondary" onClick={downloadTemplate}>
<ArrowDownTray />
Descargar plantilla
</Button>
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
<ArrowUpTray />
Subir CSV
</Button>
<input
ref={inputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) handleFile(file);
}}
/>
</div>
</div>
<div className="px-6 py-4">
<Text size="small" className="text-ui-fg-subtle">
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
separan con <code>;</code> dentro de la misma celda. Si <code>categoria</code>/<code>coleccion</code> no
coinciden con una ya existente, se crean nuevas. Si <code>titulo</code> coincide con un producto ya
importado, no se duplica: se le agregan las fotos nuevas y se actualiza la descripción si la fila trae una.
Cada foto se descarga y se guarda en el almacenamiento propio de la tienda (no queda enlazada al sitio de
origen).
</Text>
</div>
{results && (
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-ui-fg-subtle">
<th className="w-10 py-2" />
<th className="py-2 font-medium">Fila</th>
<th className="py-2 font-medium">Producto</th>
<th className="py-2 font-medium">Detalle</th>
</tr>
</thead>
<tbody>
{results.map((result) => (
<tr key={result.row} className="border-b last:border-0">
<td className="py-2">
{result.ok ? (
<CheckCircleSolid className="text-ui-tag-green-icon" />
) : (
<XCircleSolid className="text-ui-tag-red-icon" />
)}
</td>
<td className="py-2">{result.row}</td>
<td className="py-2">{result.title}</td>
<td className="py-2">
{result.ok ? (
<Badge color="green">{result.action === "updated" ? "Actualizado (ya existía)" : "Importado"}</Badge>
) : (
<span className="text-rose-600">{result.error}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Importación",
icon: ArrowUpTray,
});
export default ProductImportPanel;
@@ -0,0 +1,194 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { Buildings } from "@medusajs/icons";
import { Badge, Button, Checkbox, Container, Heading, Input, Switch, Text, toast } from "@medusajs/ui";
import { useEffect, useState } from "react";
import { sdk } from "../../lib/sdk";
// Panel simplificado para alguien no tecnico (la esposa del dueno del
// negocio) -- una fila por producto (este catalogo es 1 variante por
// producto, confirmado en vivo), switch para activar seguimiento de
// stock y un input de cantidad que guarda solo. La vista nativa de
// Medusa Admin (Inventory) sigue disponible para el dueno, esto no la
// reemplaza, es un atajo.
const LOW_STOCK_THRESHOLD = 5;
type Row = {
productId: string;
variantId: string | null;
title: string;
thumbnail: string | null;
sku: string | null;
managed: boolean;
quantity: number;
};
async function fetchRows(): Promise<Row[]> {
const data = await sdk.client.fetch<{ rows: Row[] }>("/admin/inventory-panel");
return data.rows;
}
async function saveRow(row: { productId: string; variantId: string; managed?: boolean; quantity?: number }) {
await sdk.client.fetch("/admin/inventory-panel", { method: "post", body: row });
}
const InventoryPanel = () => {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
fetchRows().then((data) => {
setRows(data);
setLoading(false);
});
}, []);
function toggleSelected(productId: string, checked: boolean) {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(productId);
else next.delete(productId);
return next;
});
}
async function updateManaged(row: Row, managed: boolean) {
if (!row.variantId) return;
setSavingId(row.productId);
try {
await saveRow({ productId: row.productId, variantId: row.variantId, managed });
setRows((prev) =>
prev.map((r) => (r.productId === row.productId ? { ...r, managed } : r)),
);
} catch {
toast.error("No se pudo guardar", { description: row.title });
} finally {
setSavingId(null);
}
}
async function updateQuantity(row: Row, quantity: number) {
if (!row.variantId || Number.isNaN(quantity) || quantity < 0) return;
setSavingId(row.productId);
try {
await saveRow({ productId: row.productId, variantId: row.variantId, quantity });
setRows((prev) =>
prev.map((r) => (r.productId === row.productId ? { ...r, quantity } : r)),
);
toast.success("Stock actualizado", { description: row.title });
} catch {
toast.error("No se pudo guardar", { description: row.title });
} finally {
setSavingId(null);
}
}
async function bulkSetManaged(managed: boolean) {
const targets = rows.filter((r) => selected.has(r.productId) && r.variantId);
for (const row of targets) {
await updateManaged(row, managed);
}
setSelected(new Set());
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading level="h2">Inventario</Heading>
<Text size="small" className="text-ui-fg-subtle">
Activa el seguimiento de stock y ajusta la cantidad disponible por producto.
</Text>
</div>
{selected.size > 0 && (
<div className="flex gap-2">
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(true)}>
Activar seguimiento ({selected.size})
</Button>
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(false)}>
Desactivar seguimiento
</Button>
</div>
)}
</div>
{loading ? (
<div className="px-6 py-8">
<Text className="text-ui-fg-subtle">Cargando…</Text>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-ui-fg-subtle">
<th className="w-10 px-6 py-3" />
<th className="px-3 py-3 font-medium">Producto</th>
<th className="px-3 py-3 font-medium">SKU</th>
<th className="px-3 py-3 font-medium">Trackear stock</th>
<th className="px-3 py-3 font-medium">Cantidad disponible</th>
<th className="px-3 py-3 font-medium" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.productId} className="border-b last:border-0">
<td className="px-6 py-3">
<Checkbox
checked={selected.has(row.productId)}
onCheckedChange={(checked) => toggleSelected(row.productId, checked === true)}
/>
</td>
<td className="flex items-center gap-3 px-3 py-3">
{row.thumbnail ? (
<img src={row.thumbnail} alt="" className="size-8 rounded object-cover" />
) : (
<div className="size-8 rounded bg-ui-bg-subtle" />
)}
<Text>{row.title}</Text>
</td>
<td className="px-3 py-3">
<Text className="text-ui-fg-subtle">{row.sku ?? "—"}</Text>
</td>
<td className="px-3 py-3">
<Switch
checked={row.managed}
disabled={savingId === row.productId}
onCheckedChange={(checked) => updateManaged(row, checked)}
/>
</td>
<td className="px-3 py-3">
<Input
type="number"
min={0}
defaultValue={row.quantity}
disabled={!row.managed || savingId === row.productId}
className="w-24"
onBlur={(event) => {
const value = Number(event.target.value);
if (value !== row.quantity) updateQuantity(row, value);
}}
/>
</td>
<td className="px-3 py-3">
{row.managed && row.quantity <= LOW_STOCK_THRESHOLD && (
<Badge color="red">Stock bajo</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Inventario",
icon: Buildings,
});
export default InventoryPanel;
@@ -0,0 +1,152 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import {
createInventoryItemsWorkflow,
updateInventoryLevelsWorkflow,
updateProductVariantsWorkflow,
} from "@medusajs/medusa/core-flows";
import { attachInventoryItemToVariantWorkflow } from "../../../workflows/attach-inventory-item-to-variant";
// Panel simplificado de inventario (pensado para un usuario no tecnico,
// no reemplaza la vista nativa de Medusa Admin -- ver ADR/memoria del
// bug de Firefox Android, tema aparte). Un producto = una variante en
// este catalogo real (confirmado en vivo contra /store/products antes
// de asumirlo), asi que la tabla trabaja a nivel de producto.
const PRODUCT_FIELDS = [
"id",
"title",
"thumbnail",
"status",
"variants.id",
"variants.sku",
"variants.manage_inventory",
"variants.inventory_items.inventory_item_id",
"variants.inventory_items.inventory.location_levels.id",
"variants.inventory_items.inventory.location_levels.location_id",
"variants.inventory_items.inventory.location_levels.stocked_quantity",
];
async function defaultLocationId(req: MedusaRequest): Promise<string | null> {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: locations = [] } = await query.graph({
entity: "stock_location",
fields: ["id"],
});
return locations[0]?.id ?? null;
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const locationId = await defaultLocationId(req);
const { data: products = [] } = await query.graph({
entity: "product",
fields: PRODUCT_FIELDS,
});
const rows = products.map((product) => {
const variant = product.variants?.[0];
const inventoryItem = variant?.inventory_items?.[0];
const level = inventoryItem?.inventory?.location_levels?.find(
(candidate) => candidate?.location_id === locationId,
);
return {
productId: product.id,
variantId: variant?.id ?? null,
title: product.title,
thumbnail: product.thumbnail,
status: product.status,
sku: variant?.sku ?? null,
managed: variant?.manage_inventory ?? false,
quantity: level?.stocked_quantity ?? 0,
};
});
res.json({ rows, locationId });
}
type PanelUpdateBody = {
productId: string;
variantId: string;
managed?: boolean;
quantity?: number;
};
export async function POST(req: MedusaRequest<PanelUpdateBody>, res: MedusaResponse) {
const { productId, variantId, managed, quantity } = req.body;
if (!productId || !variantId) {
res.status(400).json({ error: "productId y variantId son obligatorios" });
return;
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const locationId = await defaultLocationId(req);
if (!locationId) {
res.status(400).json({ error: "No hay una bodega (stock location) configurada" });
return;
}
const { data: products = [] } = await query.graph({
entity: "product",
fields: PRODUCT_FIELDS,
filters: { id: productId },
});
const variant = products[0]?.variants?.find((v: { id: string }) => v.id === variantId);
const inventoryItem = variant?.inventory_items?.[0];
let inventoryItemId: string | undefined = inventoryItem?.inventory_item_id;
if (managed === true && !inventoryItemId) {
// Primera vez que se activa el seguimiento para esta variante -- no
// trae InventoryItem todavia (confirmado en vivo: togglear
// manage_inventory=true en una variante existente NO crea uno solo,
// a diferencia de crear un producto nuevo con manage_inventory
// desde el inicio). Se crea con el nivel de stock inicial y se
// vincula aparte.
const { result: items } = await createInventoryItemsWorkflow(req.scope).run({
input: {
items: [
{
title: variant?.sku ? undefined : products[0]?.title,
sku: variant?.sku ?? undefined,
location_levels: [{ location_id: locationId, stocked_quantity: quantity ?? 0 }],
},
],
},
});
inventoryItemId = items[0]?.id;
if (inventoryItemId) {
await attachInventoryItemToVariantWorkflow(req.scope).run({
input: { inventoryItemId, variantId },
});
}
await updateProductVariantsWorkflow(req.scope).run({
input: { product_variants: [{ id: variantId, manage_inventory: true }] },
});
} else {
if (typeof managed === "boolean") {
await updateProductVariantsWorkflow(req.scope).run({
input: { product_variants: [{ id: variantId, manage_inventory: managed }] },
});
}
if (typeof quantity === "number" && inventoryItemId) {
const level = inventoryItem?.inventory?.location_levels?.find(
(candidate) => candidate?.location_id === locationId,
);
await updateInventoryLevelsWorkflow(req.scope).run({
input: {
updates: [
{
id: level?.id,
inventory_item_id: inventoryItemId,
location_id: locationId,
stocked_quantity: quantity,
},
],
},
});
}
}
res.json({ ok: true });
}
@@ -0,0 +1,355 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { Modules } from "@medusajs/framework/utils";
import type {
IFulfillmentModuleService,
IProductModuleService,
ISalesChannelModuleService,
IStockLocationService,
} from "@medusajs/framework/types";
import {
createCollectionsWorkflow,
createInventoryItemsWorkflow,
createProductCategoriesWorkflow,
createProductsWorkflow,
updateProductsWorkflow,
uploadFilesWorkflow,
} from "@medusajs/medusa/core-flows";
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// Importador masivo de productos via CSV -- para alguien que compra
// producto fisico en Amazon/eBay/etc. y quiere cargarlo rapido a la
// tienda, no para dropshipping automatizado (ver conversacion: se evaluo
// y se descarto raspar catalogos de terceros sin autorizacion). Cada
// fila se procesa independiente (una foto rota o categoria mal escrita
// no tumba el resto del archivo).
//
// categoria/coleccion: si el nombre no matchea una categoria/coleccion
// ya existente (case-insensitive), se crea nueva -- antes se descartaba
// en silencio y el producto quedaba sin categoria/coleccion sin avisar
// (confirmado en vivo: "Hello Kitty & Friends" nunca se creo como
// coleccion). El handle de la nueva categoria/coleccion se genera con
// el mismo slugify() ASCII-safe de mas abajo, por la misma razon que el
// handle del producto: el default de Medusa no quita tildes/enies y
// rompe la ruta publica.
//
// Re-importar el mismo titulo (ej. para agregar mas fotos o actualizar
// la descripcion de un producto ya cargado) actualiza el producto
// existente en vez de crear uno nuevo -- antes cada re-importacion
// 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 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 ->
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS).
// El bucket (S3_BUCKET=ari-products) es plano -- el provider de S3 de
// Medusa descarta cualquier subcarpeta del filename (usa solo
// path.parse().name + ulid, confirmado leyendo
// @medusajs/file-s3/dist/services/s3-file.js), asi que no hay una
// carpeta separada real para lo importado -- queda mezclado con el
// resto de fotos del catalogo, mismo criterio de nombres unicos por
// ulid que ya usa todo el sitio.
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const IMAGE_FETCH_TIMEOUT_MS = 15000;
const OPTION_TITLE = "Presentación";
const DEFAULT_VARIANT_VALUE = "Estandar";
type ImportRow = {
titulo: string;
descripcion: string;
precio: string;
categoria: string;
coleccion: string;
cantidad: string;
fotos: string;
};
type RowResult = {
row: number;
title: string;
ok: boolean;
action?: "created" | "updated";
error?: string;
};
// Medusa genera el handle por defecto solo en minusculas + espacios a
// guiones, sin quitar tildes/enies -- confirmado en vivo: 3 productos
// importados con titulo acentuado (ej. "niñas", "Lógicos") quedaron con
// handle como "...niñas..." y daban 404 real al entrar por
// shop.cruzcloud.net (el tunel/ingress publico rompe rutas con UTF-8
// no-ASCII en el path, aunque el mismo request funciona perfecto pegandole
// directo al servicio dentro del cluster). Se genera el handle nosotros
// 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")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80)
.replace(/-+$/g, "");
return `${base || "producto"}-${uniquePart}`;
}
function extensionFromContentType(contentType: string | null, url: string): string {
if (contentType?.includes("png")) return "png";
if (contentType?.includes("webp")) return "webp";
if (contentType?.includes("gif")) return "gif";
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) return "jpg";
const match = url.match(/\.(jpe?g|png|webp|gif)(?:\?|$)/i);
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
}
async function downloadImageAsBase64(url: string): Promise<{ content: string; mimeType: string; extension: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`No se pudo descargar la imagen (${response.status})`);
}
const contentType = response.headers.get("content-type");
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_BYTES) {
throw new Error(`Imagen supera ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
}
const extension = extensionFromContentType(contentType, url);
return {
content: buffer.toString("base64"),
mimeType: contentType?.split(";")[0] ?? `image/${extension === "jpg" ? "jpeg" : extension}`,
extension,
};
} finally {
clearTimeout(timeout);
}
}
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Plantilla descargable -- misma fuente de verdad que valida el POST,
// asi que las columnas nunca quedan desincronizadas entre lo que se
// ofrece descargar y lo que realmente se procesa.
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
const example =
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de accion","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
const csv = `${header}\n${example}\n`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
res.send(csv);
}
export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaResponse) {
const csvText = req.body?.csv;
if (!csvText || typeof csvText !== "string") {
res.status(400).json({ error: "Falta el contenido CSV" });
return;
}
const rows = csvRowsToObjects(parseCsv(csvText)) as unknown as ImportRow[];
if (rows.length === 0) {
res.status(400).json({ error: "El archivo no tiene filas de datos" });
return;
}
const stockLocationService = req.scope.resolve<IStockLocationService>(Modules.STOCK_LOCATION);
const fulfillmentService = req.scope.resolve<IFulfillmentModuleService>(Modules.FULFILLMENT);
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
const [locations, shippingProfiles, salesChannels, categories, collections, existingProducts] = await Promise.all([
stockLocationService.listStockLocations({}),
fulfillmentService.listShippingProfiles({}),
salesChannelService.listSalesChannels({}),
// select explicito -- sin el, name/title llegaban undefined (el
// default de estos module services no siempre trae todos los
// escalares, a diferencia de la capa HTTP del Admin API, confirmado
// en vivo: la ruta /admin/product-categories con fields=id,name si
// los trae completos).
productService.listProductCategories({}, { select: ["id", "name"] }),
productService.listProductCollections({}, { select: ["id", "title"] }),
productService.listProducts({}, { select: ["id", "title"], relations: ["images"] }),
]);
const locationId = locations[0]?.id;
const shippingProfileId = shippingProfiles.find((p) => p.type === "default")?.id ?? shippingProfiles[0]?.id;
const salesChannelId = salesChannels[0]?.id;
if (!locationId || !shippingProfileId || !salesChannelId) {
res.status(400).json({
error: "Faltan datos base de la tienda (bodega, perfil de envío o canal de venta)",
});
return;
}
const categoryByName = new Map(
categories.filter((c) => c.name).map((c) => [normalizeTitleForMatch(c.name), c.id]),
);
const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(c.title), c.id]),
);
const productByTitle = new Map(
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
);
const results: RowResult[] = [];
for (let index = 0; index < rows.length; index++) {
const rowNumber = index + 2; // +1 header, +1 base 1
const row = rows[index];
const title = row.titulo?.trim();
try {
if (!title) throw new Error("Falta el título");
const price = Number(row.precio);
if (!Number.isFinite(price) || price <= 0) throw new Error("Precio inválido");
const imageUrls = (row.fotos ?? "")
.split(";")
.map((u) => u.trim())
.filter(Boolean);
if (imageUrls.length === 0) throw new Error("Falta al menos una foto");
const downloaded = await Promise.all(imageUrls.map((url) => downloadImageAsBase64(url)));
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
input: {
files: downloaded.map((file, i) => ({
filename: `${Date.now()}-${index}-${i}.${file.extension}`,
mimeType: file.mimeType,
content: file.content,
access: "public" as const,
})),
},
});
const existingProduct = productByTitle.get(normalizeTitleForMatch(title));
if (existingProduct) {
await updateProductsWorkflow(req.scope).run({
input: {
products: [
{
id: existingProduct.id,
description: row.descripcion?.trim() || undefined,
images: [...(existingProduct.images ?? []).map((image) => ({ url: image.url })), ...uploadedFiles.map((f) => ({ url: f.url }))],
},
],
},
});
results.push({ row: rowNumber, title, ok: true, action: "updated" });
continue;
}
const quantity = Number(row.cantidad);
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
input: {
items: [
{
title,
location_levels: [
{ location_id: locationId, stocked_quantity: Number.isFinite(quantity) && quantity >= 0 ? quantity : 1 },
],
},
],
},
});
const inventoryItemId = inventoryItems[0]?.id;
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryName = row.categoria?.trim();
let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined;
if (categoryName && !categoryId) {
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
input: {
product_categories: [
{ name: categoryName, handle: slugify(categoryName, `${Date.now().toString(36)}${index}`) },
],
},
});
categoryId = createdCategories[0].id;
categoryByName.set(normalizeTitleForMatch(categoryName), categoryId);
}
const collectionName = row.coleccion?.trim();
let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined;
if (collectionName && !collectionId) {
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
input: {
collections: [
{ title: collectionName, handle: slugify(collectionName, `${Date.now().toString(36)}${index}`) },
],
},
});
collectionId = createdCollections[0].id;
collectionByName.set(normalizeTitleForMatch(collectionName), collectionId);
}
await createProductsWorkflow(req.scope).run({
input: {
products: [
{
title,
handle: slugify(title, `${Date.now().toString(36)}${index}`),
description: row.descripcion || undefined,
status: "published",
thumbnail: uploadedFiles[0]?.url,
images: uploadedFiles.map((f) => ({ url: f.url })),
category_ids: categoryId ? [categoryId] : undefined,
collection_id: collectionId,
shipping_profile_id: shippingProfileId,
sales_channels: [{ id: salesChannelId }],
options: [{ title: OPTION_TITLE, values: [DEFAULT_VARIANT_VALUE] }],
variants: [
{
title: DEFAULT_VARIANT_VALUE,
options: { [OPTION_TITLE]: DEFAULT_VARIANT_VALUE },
manage_inventory: true,
inventory_items: [{ inventory_item_id: inventoryItemId }],
prices: [{ amount: price, currency_code: "cop" }],
},
],
},
],
},
});
results.push({ row: rowNumber, title, ok: true, action: "created" });
} catch (error) {
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
}
}
res.json({ results });
}
@@ -0,0 +1,58 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { createEpaycoClient, type EpaycoEnv } from "../../../../lib/epayco";
// Lista de bancos PSE -- no hay forma de pedirle esto al modulo de pago
// custom directamente (AbstractPaymentProvider no expone sus metodos
// internos fuera del contrato de pago), asi que esta ruta llama a
// ePayco con las mismas credenciales de medusa-config.ts. Cache en
// memoria (sin Redis/cron), mismo criterio de simplicidad que ya usa la
// seccion de Meilisearch en medusa-config.ts.
//
// El shape exacto de la respuesta de bank.getBanks() no quedo verificado
// 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;
export async function GET(req: MedusaRequest, res: MedusaResponse) {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
res.json({ banks: cache.banks });
return;
}
try {
const client = createEpaycoClient({
publicKey: process.env.EPAYCO_PUBLIC_KEY ?? "",
privateKey: process.env.EPAYCO_PRIVATE_KEY ?? "",
pCustIdCliente: process.env.EPAYCO_P_CUST_ID_CLIENTE ?? "",
pKey: process.env.EPAYCO_P_KEY ?? "",
env: (process.env.EPAYCO_ENV as EpaycoEnv) || "sandbox",
});
const response = await client.bank.getBanks();
const rawBanks = (response.data ?? response.banks ?? response) as unknown;
const list = Array.isArray(rawBanks) ? rawBanks : [];
const banks = list
.map((bank) => {
const record = bank as Record<string, unknown>;
const pseCode = record.bankCode ?? record.bank_code ?? record.codigo ?? record.id;
const description = record.bankName ?? record.bank_name ?? record.nombre ?? record.name ?? record.description;
return typeof pseCode === "string" || typeof pseCode === "number"
? { pseCode: String(pseCode), description: String(description ?? pseCode) }
: null;
})
.filter((bank): bank is { pseCode: string; description: string } => bank !== null && bank.pseCode !== "0");
cache = { banks, fetchedAt: Date.now() };
res.json({ banks });
} catch (error) {
res.json({ banks: cache?.banks ?? [], error: (error as Error).message });
}
}
+70
View File
@@ -0,0 +1,70 @@
// Parser CSV minimo (RFC4180: campos entre comillas, comillas escapadas
// como "", comas y saltos de linea dentro de campos citados) -- se evita
// sumar una dependencia nueva (papaparse/csv-parse) para un formato que
// nosotros mismos controlamos via la plantilla descargable.
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
while (i < normalized.length) {
const char = normalized[i];
if (inQuotes) {
if (char === '"') {
if (normalized[i + 1] === '"') {
field += '"';
i += 2;
continue;
}
inQuotes = false;
i += 1;
continue;
}
field += char;
i += 1;
continue;
}
if (char === '"') {
inQuotes = true;
i += 1;
continue;
}
if (char === ",") {
row.push(field);
field = "";
i += 1;
continue;
}
if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
i += 1;
continue;
}
field += char;
i += 1;
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((cell) => cell.trim().length > 0));
}
export function csvRowsToObjects(rows: string[][]): Record<string, string>[] {
const [header, ...body] = rows;
if (!header) return [];
const keys = header.map((h) => h.trim().toLowerCase());
return body.map((cols) => {
const obj: Record<string, string> = {};
keys.forEach((key, index) => {
obj[key] = (cols[index] ?? "").trim();
});
return obj;
});
}
@@ -0,0 +1,179 @@
// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones
// del checkout): cotización y generación de guía real por peso/volumen,
// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en
// vivo contra la cuenta real del usuario (apiDoc real,
// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no
// renderiza sin JS, pero ese archivo generado por apidoc trae la spec
// completa) y una llamada de prueba real que devolvió 200 con tarifas
// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC.
const BASE_URL = "https://api.envioclickpro.com.co/api/v2";
export interface EnvioClickCredentials {
apiKey: string;
}
export interface EnvioClickPackage {
weight: number;
height: number;
length: number;
// "widht" es un typo real de la API (confirmado en el body de
// /shipment de la doc oficial), no un error nuestro -- solo se usa acá,
// en el request de /quotation el campo si se llama "width" (confirmado
// tambien contra la doc real, son dos endpoints con esa inconsistencia).
width: number;
}
export interface EnvioClickRate {
idRate: number;
idProduct: number;
product: string;
idCarrier: number;
carrier: string;
flete: number;
minimumInsurance: number;
extraInsurance: number;
deliveryDays: number;
cod: boolean;
}
export interface EnvioClickAddress {
daneCode: string;
address: string;
}
async function envioclickRequest(
credentials: EnvioClickCredentials,
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Header real confirmado contra la doc oficial y una llamada en
// vivo -- "Authorization" a secas (sin "Bearer"), NO
// "AuthorizationKey" como usaba el plugin de WordPress de
// referencia (ese era para la API v1, desactualizada).
Authorization: credentials.apiKey,
},
body: JSON.stringify(body),
});
const text = await response.text();
let json: Record<string, unknown>;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`);
}
if (json.status !== "OK") {
throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`);
}
return json;
}
// Solo 1 paquete por cotizacion (limite real documentado y confirmado:
// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete).
export async function quoteShipment(
credentials: EnvioClickCredentials,
params: {
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickAddress;
destination: EnvioClickAddress;
},
): Promise<EnvioClickRate[]> {
const json = await envioclickRequest(credentials, "/quotation", {
packages: [params.pkg],
description: params.description,
contentValue: params.contentValue,
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return (data.rates as EnvioClickRate[]) ?? [];
}
export interface EnvioClickPerson {
company: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
suburb: string;
crossStreet: string;
reference: string;
daneCode: string;
}
export interface CreateGuideResult {
guide: string;
url: string;
tracker: string;
idOrder: number;
}
export async function createGuide(
credentials: EnvioClickCredentials,
sandbox: boolean,
params: {
idRate: number;
myShipmentReference: string;
externalOrderId: string;
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickPerson;
destination: EnvioClickPerson;
},
): Promise<CreateGuideResult> {
// /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la
// cotizacion siempre usa tarifas reales de las transportadoras, no
// tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a
// la URL de producción para que puedas generar guías reales").
const path = sandbox ? "/shipment_sandbox" : "/shipment";
const json = await envioclickRequest(credentials, path, {
idRate: params.idRate,
myShipmentReference: params.myShipmentReference,
external_order_id: params.externalOrderId,
requestPickup: false,
pickupDate: new Date().toISOString().slice(0, 10),
insurance: false,
description: params.description,
contentValue: params.contentValue,
packages: [
{
weight: params.pkg.weight,
height: params.pkg.height,
length: params.pkg.length,
widht: params.pkg.width,
},
],
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return {
guide: String(data.guide ?? ""),
url: String(data.url ?? ""),
tracker: String(data.tracker ?? ""),
idOrder: Number(data.idOrder ?? 0),
};
}
// Hasta 50 ordenes por llamada (limite real documentado).
export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise<void> {
await envioclickRequest(credentials, "cancellation/batch/order", { idOrders });
}
// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio,
// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa
// lib/data/colombia.ts del storefront, confirmado contra su propio
// comentario) con el padding real que espera EnvioClick (8 caracteres,
// confirmado contra su propio ejemplo real: "11001000" para Bogota =
// "11001" + "000").
export function toEnvioClickDaneCode(divipolaCityId: number): string {
return `${String(divipolaCityId).padStart(5, "0")}000`;
}
@@ -0,0 +1,68 @@
import { createHash } from "node:crypto";
// ePayco (100% de Davivienda desde 2024, ver ADR 0009 -- tercer proveedor
// evaluado tras PayU LatAm/Rapyd, elegido por comision PSE mas baja para
// bajo volumen y tarifa preferencial con cuenta Davivienda, que el
// usuario ya tiene). A diferencia de PayU/Rapyd, no llamamos la API REST
// cruda nosotros: usamos el SDK oficial `epayco-sdk-node` (mantenido por
// github.com/epayco, ultimo publish reciente) porque su protocolo real
// -- leido del codigo fuente, no de la doc -- es bastante mas raro de lo
// que parece en los ejemplos: cada campo del payload se cifra con
// AES-CBC usando la private key como llave (IV fijo en cero) ANTES de
// mandarse a secure.payco.co, ademas de un login previo a
// api.secure.payco.co/v1/auth/login que da un bearer token que tambien
// va en la request. Reimplementar esto a mano tiene mucho mas riesgo de
// error que delegarlo al SDK del propio proveedor.
export type EpaycoEnv = "sandbox" | "production";
export interface EpaycoCredentials {
publicKey: string;
privateKey: string;
pCustIdCliente: string;
pKey: string;
env: EpaycoEnv;
}
// Sin tipos de TypeScript en el paquete (confirmado: package.json no
// declara "types") -- se tipa manualmente el subset que usamos.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const EpaycoSdk = require("epayco-sdk-node");
export interface EpaycoClient {
bank: {
create(options: Record<string, unknown>): Promise<Record<string, unknown>>;
get(transactionId: string): Promise<Record<string, unknown>>;
getBanks(): Promise<Record<string, unknown>>;
};
}
export function createEpaycoClient(credentials: EpaycoCredentials): EpaycoClient {
return EpaycoSdk({
apiKey: credentials.publicKey,
privateKey: credentials.privateKey,
lang: "ES",
test: credentials.env === "sandbox",
});
}
// Firma del webhook de confirmacion (url_confirmation) -- formula
// verificada contra la documentacion real de ePayco:
// sha256(p_cust_id_cliente^p_key^x_ref_payco^x_transaction_id^x_amount^x_currency_code).
// p_cust_id_cliente/p_key son valores propios del dashboard de ePayco
// (Configuracion > Llaves secretas), distintos de publicKey/privateKey
// -- confirmar contra el dashboard real antes de la primera prueba.
// Falla cerrado: cualquier mismatch devuelve false, nunca se traga la
// excepcion.
export function verifyWebhookSignature(params: {
pCustIdCliente: string;
pKey: string;
refPayco: string;
transactionId: string;
amount: string;
currencyCode: string;
signature: string;
}): boolean {
const raw = `${params.pCustIdCliente}^${params.pKey}^${params.refPayco}^${params.transactionId}^${params.amount}^${params.currencyCode}`;
const expected = createHash("sha256").update(raw).digest("hex");
return expected.toLowerCase() === params.signature.toLowerCase();
}
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import EnvioClickFulfillmentService from "./service";
export default ModuleProvider(Modules.FULFILLMENT, {
services: [EnvioClickFulfillmentService],
});
@@ -0,0 +1,275 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils";
import type {
CalculatedShippingOptionPrice,
CalculateShippingOptionPriceContext,
CalculateShippingOptionPriceDTO,
CreateFulfillmentResult,
CreateShippingOptionDTO,
FulfillmentDTO,
FulfillmentItemDTO,
FulfillmentOption,
FulfillmentOrderDTO,
Logger,
ValidateFulfillmentDataContext,
} from "@medusajs/framework/types";
import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick";
import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// Contexto minimo compartido entre calculatePrice/createFulfillment --
// ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado
// como metadata en la direccion por el checkout -- ver ADR 0009 y
// checkout-client.tsx del storefront) y el paquete agregado del pedido.
interface AddressLike {
metadata?: Record<string, unknown> | null;
address_1?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
email?: string | null;
}
function destinationDaneCode(address: AddressLike | null | undefined): string | null {
const cityId = address?.metadata?.cityId;
return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null;
}
// Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por
// cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun
// item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver
// plan de implementacion), se usa el paquete de fallback conservador
// completo en vez de mezclar datos reales con inventados a medias.
function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) {
if (items.length === 0) return { ...DEFAULT_PACKAGE };
let totalWeight = 0;
for (const item of items) {
const weight = item.variant?.weight;
if (!weight) return { ...DEFAULT_PACKAGE };
totalWeight += weight * item.quantity;
}
return { ...DEFAULT_PACKAGE, weight: totalWeight };
}
export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "envioclick";
protected logger_: Logger;
protected options_: EnvioClickOptions;
constructor(container: InjectedDependencies, options: EnvioClickOptions) {
super();
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) {
if (!options[key]) {
throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
// Una sola opcion: siempre toma la tarifa mas barata entre las
// transportadoras que cotizan, mismo criterio que el plugin oficial
// de WooCommerce de referencia ("toma el precio mas economico").
return [{ id: "standard", name: "Envío nacional (calculado)" }];
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
_context: ValidateFulfillmentDataContext,
): Promise<any> {
return data;
}
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
return true;
}
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
return true;
}
// Se invoca en vivo durante el checkout cada vez que se piden/refrescan
// las shipping options del carrito. Medusa advierte explicitamente que
// un error acá bloquea el checkout entero -- por eso NUNCA se deja
// propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA,
// EnvioClick caido) cae al precio fijo que ya se usaba antes de esta
// integracion, para no romper la compra.
async calculatePrice(
optionData: CalculateShippingOptionPriceDTO["optionData"],
_data: CalculateShippingOptionPriceDTO["data"],
context: CalculateShippingOptionPriceContext,
): Promise<CalculatedShippingOptionPrice> {
const fallback: CalculatedShippingOptionPrice = {
calculated_amount: FALLBACK_PRICE_COP,
is_calculated_price_tax_inclusive: false,
};
try {
const address = context.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo");
return fallback;
}
const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) return fallback;
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
return {
calculated_amount: cheapest.flete + cheapest.minimumInsurance,
is_calculated_price_tax_inclusive: false,
};
} catch (error) {
this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`);
return fallback;
}
}
// A diferencia de calculatePrice, acá SÍ se deja propagar el error --
// el idRate de la cotización mostrada en checkout no se persiste en
// ningún lado (CalculatedShippingOptionPrice no tiene un campo `data`
// para eso, confirmado leyendo el tipo real), así que se vuelve a
// cotizar en el momento de generar la guía (más correcto igual: el
// fulfillment puede pasar horas/días después del checkout, un idRate
// viejo podría estar vencido) y se reserva con la tarifa más barata de
// esa cotización fresca.
async createFulfillment(
_data: Record<string, unknown>,
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
order: Partial<FulfillmentOrderDTO> | undefined,
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
): Promise<CreateFulfillmentResult> {
const address = order?.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío.");
}
const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) {
throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía.");
}
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
const guide = await createGuide(
{ apiKey: this.options_.apiKey },
this.options_.sandbox,
{
idRate: cheapest.idRate,
myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`,
externalOrderId: String(order?.id ?? ""),
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: {
company: this.options_.originCompany,
firstName: this.options_.originFirstName,
lastName: this.options_.originLastName,
email: this.options_.originEmail,
phone: this.options_.originPhone,
address: this.options_.originAddress,
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode: this.options_.originDaneCode,
},
destination: {
company: "N/A",
firstName: address?.first_name || "Cliente",
lastName: address?.last_name || "ARI",
email: address?.email || order?.email || "[email protected]",
phone: address?.phone || "0000000000",
address: address?.address_1 || "Dirección de entrega",
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode,
},
},
);
return {
data: {
idOrder: guide.idOrder,
tracker: guide.tracker,
labelUrl: guide.url,
},
labels: [
{
tracking_number: guide.tracker,
tracking_url: guide.url,
label_url: guide.url,
},
],
};
}
async cancelFulfillment(data: Record<string, unknown>): Promise<any> {
const idOrder = data.idOrder;
if (typeof idOrder !== "number") {
this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick");
return { data };
}
await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]);
return { data };
}
async getFulfillmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
// Devoluciones no estan en alcance de esta primera version.
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
}
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
return undefined;
}
}
@@ -0,0 +1,33 @@
export interface EnvioClickOptions {
apiKey: string;
// El unico endpoint con modo de pruebas real es la generacion de guia
// (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas
// reales, no hay nada que alternar ahi.
sandbox: boolean;
// Datos de la bodega de origen -- la stock location real todavia no
// tiene direccion fisica cargada ("Sin dirección física registrada"),
// asi que estos vienen de config hasta que se cargue una real.
originDaneCode: string;
originAddress: string;
originCompany: string;
originFirstName: string;
originLastName: string;
originEmail: string;
originPhone: string;
}
// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones
// reales del pedido (hoy ningun producto los tiene cargados) -- mismo
// valor que ya usaba la shipping option fija de ADR 0009, para no
// empeorar la experiencia mientras no haya datos reales.
export const FALLBACK_PRICE_COP = 15000;
// Paquete por defecto cuando algun item del carrito no tiene
// peso/dimensiones reales -- conservador, no preciso. Ver nota en el
// plan de implementacion: esto es una tarea de datos, no de codigo.
export const DEFAULT_PACKAGE = {
weight: 1,
length: 20,
height: 20,
width: 20,
};
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import EpaycoPseProviderService from "./service";
export default ModuleProvider(Modules.PAYMENT, {
services: [EpaycoPseProviderService],
});
@@ -0,0 +1,234 @@
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types";
import { createEpaycoClient, verifyWebhookSignature } from "../../lib/epayco";
import type { EpaycoPseInputData, EpaycoPseOptions, EpaycoPseSessionData } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// PSE via ePayco es asincrono igual que PayU/Rapyd: no hay confirmacion
// real hasta que llega el webhook (url_confirmation), nunca en la
// respuesta sincrona de bank.create(). authorizePayment/capturePayment
// por eso son casi no-ops -- el estado real lo trae siempre el webhook.
export default class EpaycoPseProviderService extends AbstractPaymentProvider<EpaycoPseOptions> {
static identifier = "epayco";
protected logger_: Logger;
protected options_: EpaycoPseOptions;
constructor(container: InjectedDependencies, options: EpaycoPseOptions) {
super(container, options);
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["publicKey", "privateKey", "pCustIdCliente", "pKey", "env", "responseUrl", "notifyUrl"]) {
if (!options[key]) {
throw new Error(`epayco-pse: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const data = input.data as unknown as EpaycoPseInputData & { session_id: string };
const sessionId = data.session_id;
const amount = new BigNumber(input.amount).numeric;
const currency = input.currency_code.toUpperCase();
const client = createEpaycoClient(this.options_);
// Campos confirmados leyendo node_modules/epayco-sdk-node/lib/keylang.json
// y tests/bank.js directamente (no la documentacion publica -- esa
// mostraba "metodoconfirmacion", el codigo real que de verdad manda
// el SDK usa "method_confirmation"). El SDK cifra estos valores y
// agrega ip/test/auth por su cuenta, no hace falta hacerlo nosotros.
const response = await client.bank.create({
bank: data.bank_code,
invoice: sessionId,
description: `Orden ARI Shopping ${sessionId}`,
value: String(amount),
tax: "0",
tax_base: "0",
currency,
type_person: data.user_type,
doc_type: data.doc_type,
doc_number: data.doc_number,
name: data.first_name,
last_name: data.last_name,
email: data.email,
country: "CO",
city: data.city,
cell_phone: data.phone,
url_response: this.options_.responseUrl,
url_confirmation: this.options_.notifyUrl,
method_confirmation: "POST",
});
if (response.error) {
throw new Error(`ePayco rechazó la transacción para la sesión ${sessionId}: ${JSON.stringify(response).slice(0, 300)}`);
}
// El nombre real del campo con la URL de redirección al banco NO
// quedó verificado contra una respuesta real de sandbox en esta
// sesión (ni la doc ni los tests del SDK lo muestran) -- se prueban
// los candidatos más probables y, si ninguno aparece, se tira un
// error con el payload completo para poder leerlo en los logs y
// corregir esto en el primer intento real contra el sandbox.
const flat = (response.data ?? response) as Record<string, unknown>;
const bankUrlCandidates = ["urlbanco", "url_banco", "urlBanco", "url", "redirect_url"];
const bankUrl = bankUrlCandidates.map((key) => flat[key]).find((value) => typeof value === "string" && value);
if (typeof bankUrl !== "string" || !bankUrl) {
throw new Error(
`ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0, 500)}`,
);
}
const sessionData: EpaycoPseSessionData = {
...data,
session_id: sessionId,
ref_payco: String(flat.ref_payco ?? flat.refPayco ?? ""),
transaction_id: String(flat.transactionID ?? flat.transaction_id ?? ""),
bank_url: bankUrl,
state: "PENDING",
amount,
currency,
};
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
}
// PSE es diferido: en el momento de completar el carrito, ePayco
// todavia no confirmo nada -- "pending_authorization" es el mecanismo
// documentado de Medusa para esto (la orden queda "awaiting" y el
// navegador ya puede redirigirse al banco).
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
return { data: input.data, status: "pending_authorization" };
}
// Casi no-op: cuando el webhook reporta "captured", ePayco ya liquido
// el dinero de su lado -- solo persiste el data actualizado.
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: input.data };
}
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
this.logger_.warn("epayco-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
return { data: input.data };
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return { data: input.data };
}
// Endpoint de reembolso de ePayco no quedó verificado en vivo en esta
// sesión -- con dinero real de por medio, mejor tirar un error
// explícito que fingir un reembolso que nunca se procesó.
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
throw new Error(
"epayco-pse: refundPayment no está implementado todavía -- procesar el reembolso manualmente desde el panel de ePayco por ahora.",
);
}
// GET /validation/v1/reference/{ref_payco} -- endpoint de verificación
// server-to-server documentado por ePayco para consultar el estado
// real de una transacción.
async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const refPayco = data?.ref_payco;
if (!refPayco) {
throw new Error("epayco-pse: retrievePayment necesita ref_payco, no está guardado en esta sesión.");
}
const response = await fetch(`https://secure.epayco.co/validation/v1/reference/${refPayco}`);
const json = await response.json();
return { data: json as Record<string, unknown> };
}
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// ePayco no tiene concepto de "modificar" una transaccion ya
// enviada. El checkout solo llega al step de pago despues de fijar
// direccion y envio, asi que el total no deberia cambiar una vez que
// existe una sesion PSE.
return { data: input.data };
}
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const state = data?.state;
if (state === "1") return { status: "captured" };
if (state === "3") return { status: "pending" };
if (state === "PENDING") return { status: "pending" };
return { status: "canceled" };
}
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
// firma no matchea, no se hace nada -- nunca se confia en un webhook
// sin firma valida.
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
const body = payload.data as Record<string, unknown>;
const refPayco = String(body.x_ref_payco ?? "");
const transactionId = String(body.x_transaction_id ?? "");
const amount = String(body.x_amount ?? "");
const currencyCode = String(body.x_currency_code ?? "");
const codState = String(body.x_cod_transaction_state ?? "");
const signature = String(body.x_signature ?? "");
// session_id sale del invoice que nosotros mandamos -- ePayco lo
// devuelve como x_id_invoice (o x_extra1 si no viniera) segun el
// webhook real; se intentan ambos.
const sessionId = String(body.x_id_invoice ?? body.x_extra1 ?? refPayco);
const validSignature = verifyWebhookSignature({
pCustIdCliente: this.options_.pCustIdCliente,
pKey: this.options_.pKey,
refPayco,
transactionId,
amount,
currencyCode,
signature,
});
if (!validSignature) {
this.logger_.warn(`epayco-pse: webhook con firma inválida para x_ref_payco=${refPayco}, ignorado`);
return { action: "not_supported" };
}
// Solo "1" (Aceptada) es exito -- cualquier otro codigo (2 Rechazada,
// 4 Fallida, 6 Reversada, 9 caducada, 10 abandonada, 11 Cancelada,
// 3 Pendiente) se trata como fallido en vez de asumir un exito
// distinto. Un "failed" de mas es reintentable; un "captured" de mas
// (dinero que nunca se aprobo) no lo es.
const action = codState === "1" ? "captured" : "failed";
return {
action,
data: {
session_id: sessionId,
amount: new BigNumber(amount || 0),
},
};
}
}
@@ -0,0 +1,54 @@
import type { EpaycoEnv } from "../../lib/epayco";
export interface EpaycoPseOptions {
publicKey: string;
privateKey: string;
// p_cust_id_cliente / p_key -- valores propios del dashboard de ePayco
// para verificar la firma del webhook, distintos de publicKey/privateKey.
pCustIdCliente: string;
pKey: string;
env: EpaycoEnv;
// A donde ePayco redirige al comprador de vuelta tras pagar en el
// banco (pagina propia del storefront, no confirma nada por si sola).
responseUrl: string;
// URL publica de confirmacion -- por-transaccion (a diferencia de
// Rapyd, que solo permite configurarlo una vez a nivel de cuenta),
// apunta a la ruta nativa de Medusa /hooks/payment/:provider.
notifyUrl: string;
}
export type EpaycoDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
// "0" persona natural, "1" persona juridica -- valores reales que espera
// el campo type_person de ePayco.
export type EpaycoUserType = "0" | "1";
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
// (recolectado en el step "address" del checkout, mas el banco/tipo de
// persona/documento del nuevo step "payment"). No viene de
// `context.customer` porque el checkout es de invitado -- ver ADR 0009.
export interface EpaycoPseInputData {
bank_code: string;
user_type: EpaycoUserType;
doc_type: EpaycoDocType;
doc_number: string;
first_name: string;
last_name: string;
email: string;
phone: string;
city: string;
}
// Lo que initiatePayment devuelve y queda guardado en `data` de la
// PaymentSession -- publico para el storefront, nunca poner credenciales
// acá. `bank_url`: nombre de campo AUN NO VERIFICADO contra una
// respuesta real de sandbox (ver comentario en service.ts) -- primer
// punto a confirmar en la primera prueba end-to-end.
export interface EpaycoPseSessionData extends EpaycoPseInputData {
session_id: string;
ref_payco: string;
transaction_id: string;
bank_url: string;
state: string;
amount: number;
currency: string;
}
@@ -0,0 +1,21 @@
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
import { attachInventoryItemToVariants } from "@medusajs/medusa/core-flows";
// attachInventoryItemToVariants es un step suelto (no un workflow
// exportado) -- confirmado leyendo el .js real instalado
// (@medusajs/core-flows/dist/inventory/steps/attach-inventory-items.js):
// internamente resuelve ContainerRegistrationKeys.LINK y crea el link
// Product<->Inventory. El campo "tag" del input es el variant_id (nombre
// generico del step, no documentado con ese detalle).
type Input = { inventoryItemId: string; variantId: string };
export const attachInventoryItemToVariantWorkflow = createWorkflow(
"attach-inventory-item-to-variant",
(input: Input) => {
return new WorkflowResponse(
attachInventoryItemToVariants([
{ inventoryItemId: input.inventoryItemId, tag: input.variantId },
]),
);
},
);
@@ -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.
@@ -37,6 +37,9 @@ flowchart LR
SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
C --> T["tmux server<br/>sesión 'lab'"]
T --> CC["proceso claude<br/>(Claude Code)"]
C --> HC["healthcheck.sh (background)<br/>cada 30 min"]
HC -.->|curl| NET["gitea.cruzcloud.net"]
HC -.->|recrea si falta| T
C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]
@@ -49,7 +52,17 @@ reinicia.
## Contenedor: `tmux-host`
La imagen se construye desde `scripts/tmux-host/Dockerfile` (antes era
un `docker run ... alpine sh -c "apk add ..."` inline — mismo contenido,
pero ahora como imagen versionada en vez de reinstalar todo en cada
arranque). El Dockerfile fija `WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud`
(la raíz del lab, con `CLAUDE.md`, `apps-registry/`, `platform-infra/` y
`scripts/`) y agrega `healthcheck.sh` (ver más abajo) como proceso en
background del contenedor.
```bash
docker build -t tmux-host:local scripts/tmux-host
docker run -d \
--name tmux-host \
--restart unless-stopped \
@@ -59,9 +72,15 @@ docker run -d \
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
--network host \
alpine sh -c "apk add --no-cache tmux docker-cli bash curl git kubectl nodejs npm && npm install -g @anthropic-ai/claude-code && git config --global --add safe.directory '*' && tail -f /dev/null"
tmux-host:local
```
`WORKDIR` hace que `docker exec` (y por lo tanto `lab-session.sh`)
arranquen ya posicionados en la raíz del lab sin tener que navegar a
mano — `lab-session.sh` además pasa la ruta explícita vía `tmux new -c`
por las dudas, para no depender de que `docker exec` herede el `WORKDIR`
de la imagen.
| Volumen | Destino | Por qué |
|---|---|---|
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
@@ -195,13 +214,200 @@ Si la sesión `lab` existe, se adjunta a ella (mismo shell, mismo
ejemplo justo después de que el contenedor se recreó desde cero— la
crea. No hay que recordar cuál de los dos casos aplica.
## Chequeo de salud automático (cada 30 min)
`scripts/tmux-host/healthcheck.sh` corre en background dentro del
propio contenedor (lanzado por `entrypoint.sh`, el `ENTRYPOINT` de la
imagen) y cada `HEALTHCHECK_INTERVAL_SEC` (1800s por defecto) hace dos
chequeos independientes, con una respuesta distinta según el tipo de
falla:
- **Conectividad de red real** (`curl` a `https://gitea.cruzcloud.net/`):
si falla, **solo se registra** en el log y se notifica por Telegram —
el contenedor nunca intenta "arreglar" la red del host. Un problema de
red puede ser de hardware/BIOS (deuda técnica ya conocida, ver
`incidente-medusa-crashloop-2026-08.md`), fuera del alcance real de un
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>`, 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
`/DATA`, no solo en el stdout del contenedor (que se pierde con
`docker logs` al recrearlo). Las notificaciones de Telegram reusan el
mismo bot/`chat_id` ya configurado para `scripts/lab-agent/`
(`lab-agent.env`), en vez de duplicar credenciales entre los dos
agentes del lab.
Para probar el intervalo sin esperar 30 minutos, se puede levantar el
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** — el proceso `tmux server` muere con el contenedor; `lab-session.sh` crea una sesión nueva automáticamente | Sí, vía el volumen montado en `/root/.claude` |
| `docker rm` + recrear el contenedor desde cero | No | 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`) |
| 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,9 +1,18 @@
# ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping
**Estado:** aceptada (implementación en curso, por sub-fases)
**Fecha:** 2026-08-27
**Estado:** aceptada, con enmienda de paleta (ver §8) — sub-fases 1 a 4
completas, sub-fase 5 parcial (ver §9)
**Fecha:** 2026-08-27 (enmendada 2026-09-06)
**Reemplaza a:** [ADR 0007](0007-identidad-visual-propia.md) (Opción 2, "Atelier cálido")
> **Nota de vigencia:** la paleta descrita en §4 ("Navy & Coral":
> marino `#122844` / coral `#D85A30` / coral claro `#F0997B`) fue la
> dirección **aprobada e implementada en las sub-fases 1-3**, pero fue
> **reemplazada el mismo día** por una paleta azul/amarillo estilo
> Walmart (rama `fix/adr0008-walmart-palette-blue-yellow`, ver §8). Los
> valores reales en producción hoy están en §8, no en §4. Se deja §4
> intacto como registro histórico de la decisión original.
## 1. Contexto
Este es el tercer intento de identidad visual propia para ARI Shopping,
@@ -168,3 +177,66 @@ definitivo; los alias se eliminan por completo al cerrar la sub-fase
`commerce-backend`, `platform-infra`, ni CI nuevo.
- `docs-portal` se actualiza con capturas/descripción del resultado
final una vez cada sub-fase esté validada en producción, no antes.
## 8. Enmienda 2026-08-27 tarde: pivote de paleta a azul/amarillo
Horas después de mergear la sub-fase 3 (product card + grid), se
mergeó `fix/adr0008-walmart-palette-blue-yellow`: la paleta "Navy &
Coral" de §4 se descartó en producción y se reemplazó por una paleta
azul/amarillo (mismo patrón "estética Walmart" ya usado como
referencia de UX en ADR 0007). Esta decisión **no quedó documentada
como enmienda en su momento** — se detecta y se registra aquí recién
el 2026-09-06, al auditar el estado real del código contra este
documento.
Valores reales en `globals.css` hoy (los nombres de token de §4 se
conservan, pero apuntan a estos valores, no a los de la tabla
original):
| Token | Valor real | Uso |
|---|---|---|
| `--color-ari-marino` | `#0055B8` (azul) | ancla: header, footer, fondo de hero, botón de búsqueda |
| `--color-ari-coral` | `#0055B8` (azul, mismo valor que marino) | CTA "Agregar al carrito", precio destacado |
| `--color-ari-coral-claro` | `#FFC220` (amarillo) | badges de oferta/urgencia, acento del isotipo, botón "yellow" del hero |
Nota: `ari-coral` y `ari-marino` son el mismo azul (`#0055B8`) — a
diferencia de la especificación original de §4, donde marino y coral
eran colores distintos. Cualquier lectura de este ADR para decisiones
de diseño futuras debe partir de esta tabla, no de §4.
El isotipo (mariposa de 4 elipses, §4) tampoco se implementó como se
describe: el logo vigente es `logo-ari-circular.svg` (rama
`adr0008-logo-circular-header`), un lockup circular distinto al
descrito en la tabla de coordenadas de §4. `logo-ari.svg` (rectangular,
el que sí sigue la spec original) quedó sin usarse en el header.
## 9. Estado real por sub-fase (auditado 2026-09-06)
Auditoría contra el código en `main`, no contra el plan de §6.
| # | Sub-fase | Estado | Evidencia |
|---|---|---|---|
| 1 | Tokens base + logo | ✅ completa (con la paleta enmendada en §8, no la de §4) | `globals.css`, `logo-ari.svg` inicial, luego reemplazado por circular en sub-fase posterior |
| 2 | Header + footer (incl. limpieza de §5) | ✅ completa | `navbar.tsx`, restos arcoíris (gradiente ámbar/lima, línea cian/violeta/fucsia de §5) eliminados |
| 3 | Product card + grid + badges | ✅ completa | `product-card.tsx` en tokens `ari-*` |
| 4 | Home (hero, tiles, secciones) | ✅ completa | `hero-carousel.tsx`, `category-tiles.tsx` (varias iteraciones: pastel → scrim → foto flotante → foto más grande, todas mergeadas) |
| 5 | Página de producto + drawers de carrito/wishlist | ⚠️ **parcial** | `product-purchase.tsx`/página de producto sí migraron (rama `adr0008-product-purchase-mobile-y-badge`). **`cart-drawer.tsx` y `wishlist-drawer.tsx` no tienen ningún token `ari-*` — nunca se tocaron.** No hay rama mergeada que los mencione. |
**Deuda abierta, no asumir resuelta:**
- Los alias de compatibilidad de §6 (`ari-ciruela`, `ari-cobre`,
`ari-carbon`, `ari-carbon-oscuro`, `ari-terracota-oscuro`,
`ari-hueso`) **siguen en uso real en `button.tsx`**
(`focus-visible:ring-ari-cobre/40`, `bg-ari-ciruela`,
`hover:border-ari-cobre`) — la condición de cierre de §6 ("los alias
se eliminan por completo al cerrar la sub-fase 5") no se cumplió.
Confirma que la sub-fase 5 no está cerrada, más allá de los drawers.
- Ramas mergeadas fuera del plan original de 5 sub-fases de §6, sin
enmienda que las registre: `adr0008-megamenu-mobile-overflow`,
`adr0008-club-ari-y-novedades`, `adr0008-club-ari-imagen-azul`. Son
trabajo real en producción, pero expanden el alcance de este ADR sin
quedar documentado como tal.
- `docs-portal` (este documento) es la primera actualización desde el
cierre de la sub-fase 3 — la condición de §7 ("se actualiza con
capturas/descripción... una vez cada sub-fase esté validada") no se
vino cumpliendo sub-fase por sub-fase como se planeó.
@@ -0,0 +1,230 @@
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
**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
El carrito hoy no completa una transacción real: arma el pedido en el
cliente y lo cierra con un botón "Continuar por WhatsApp"
(`components/layout/navbar.tsx`, `mobile-add-to-cart.tsx`) — un
placeholder intencional, no un bug. El diálogo de cuenta ya lo admite
explícitamente ("El inicio de sesión quedará conectado al backend
headless. Mientras tanto..."). Ni checkout ni auth de clientes tienen
ADR previo ni intento de implementación en el historial de git de
ningún repo del lab — este es terreno nuevo.
Investigación previa a este ADR (2026-08-28, confirmado contra código
real y documentación oficial vigente, no memoria de entrenamiento —
misma disciplina que ADR 0006):
- **Pagos:** `commerce-backend/medusa-config.ts` no registra ningún
provider de pago — el único módulo configurado hoy es
`@medusajs/medusa/file` (storage S3).
- **Envíos:** existe una sola región ("Colombia", COP), creada a mano
vía Admin API durante el incidente de precios de agosto
(`incidente-medusa-precios-region-id-2026-08.md`), no vía ningún
script de seed — no existe ningún script de seed en el repo. **No
hay ninguna shipping option configurada en ningún lado.** Sin esto
el checkout no puede completarse aunque el pago ya funcione — es un
prerequisito invisible, independiente del proveedor de pago elegido.
- **Auth:** cero código de cliente/login en todo el storefront (grep
sobre `lib/`, `app/`, `components/` sin resultados para
`customer`/`login`/`useAuth`/`/store/customers`). El módulo de
auth/customer de Medusa viene incluido en el core — falta
enteramente el lado del storefront, no el backend. **Medusa soporta
checkout como invitado**, así que auth no bloquea checkout y puede
secuenciarse por separado — fuera de alcance de este ADR.
## 1. Flujo de checkout real en Medusa v2 (Store API)
Confirmado contra `docs.medusajs.com` (2026-08-28): el flujo son 7
pasos encadenados — crear carrito → agregar items → dirección de envío
→ **seleccionar método de envío** (`POST` a la ruta de shipping method
del carrito, requiere que ya exista al menos una shipping option) →
payment collection → payment session → completar orden. Ningún paso
es opcional; el paso de método de envío es exactamente el que hoy no
tiene ninguna shipping option que ofrecer.
## 2. Estado real de soporte de pago en Medusa 2.18.0
- **Stripe es de primera clase** (`@medusajs/medusa/payment-stripe`,
incluido en el core, no un plugin de terceros — a diferencia de la
situación de Meilisearch en ADR 0006). Configuración mínima
verificada:
```ts
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [{
resolve: "@medusajs/medusa/payment-stripe",
id: "stripe",
options: { apiKey: process.env.STRIPE_API_KEY },
}],
},
}
```
Env vars: `STRIPE_API_KEY` (siempre) y `STRIPE_WEBHOOK_SECRET`
(obligatorio en producción). Tras configurar, hay que habilitar el
provider **en la región** desde el Admin — un paso manual aparte,
igual que ya pasó con la región misma.
- **Bloqueador real de negocio, no técnico: Stripe no acepta cuentas
de comercio registradas en Colombia.** Confirmado contra
`stripe.com/global` (2026-08-28) — Brasil y México sí están en la
lista de países soportados para abrir cuenta, Colombia no aparece.
La única vía sería registrar una entidad de negocio en un país
soportado, fuera de alcance realista para este lab. Esto invalida a
Stripe como opción pese a ser la integración técnicamente más
simple y mejor documentada.
- **Alternativas con presencia real en Colombia y algún plugin de
Medusa existente:**
- **PayU** (`@tax1driver/medusa-payu`): el propio repo declara
compatibilidad explícita con Medusa `v2.4.0+` — cubre nuestra
versión (2.18.0). Webhook auto-registrado en `/hooks/payment/payu`.
- **Wompi** (`medusa-payment-wompi-col`, respaldado por Bancolombia,
liquidación directa en COP): revisé el repo directamente
(2026-08-28) y **no documenta contra qué versión de Medusa se
probó, ni tiene releases/actividad reciente visible** — mayor
riesgo de compatibilidad no verificada que PayU, aunque el
encaje de negocio (banco local, moneda nativa) sea el más
directo. Antes de elegirlo habría que repetir el nivel de
verificación que ADR 0006 le hizo al plugin de Meilisearch: leer
el código fuente real, no confiar en el README.
- **Riesgo de red ya conocido, no nuevo:** cualquier webhook de pago
entra por la misma cadena Cloudflare → NPM → Traefik que ya causó
un incidente real con otro servicio
(`incidente-cloudflared-504-quic-2026-08.md`). Validar la entrega de
webhooks en vivo antes de confiar en el flujo, no asumir que
"funciona igual que todo lo demás".
## 3. Decisión de proveedor — PayU
Dato de negocio real que decidió esto (aportado por el usuario,
2026-08-28): las cuentas bancarias disponibles son Davivienda, Lulo
Bank y Banco de Bogotá — **ninguna es Bancolombia ni Nequi**.
Verificado contra fuentes reales el mismo día (no memoria):
- **Wompi exige cuenta Bancolombia o, como mínimo, cuenta Nequi para
poder registrarse como comercio** (modelo Agregador) — confirmado
contra el reglamento de comercios de Wompi y su centro de soporte.
La liquidación de ventas sí puede ir después a cualquier banco
colombiano, pero la puerta de entrada al registro sigue siendo
Bancolombia/Nequi. Con los bancos disponibles hoy, esto exigiría
abrir una cuenta Nequi nueva solo para poder registrarse — paso
extra evitable.
- **PayU solo exige "cuenta bancaria colombiana a nombre del
comercio"**, sin banco específico — Davivienda, Lulo Bank o Banco de
Bogotá califican igual, sin pasos previos. Además ya soporta **PSE**
(transferencia desde cualquier banco colombiano del cliente, sin
relación con el banco del comercio), **Nequi** y **Daviplata** como
métodos de pago para los clientes vía redirección — cubre exactamente
los métodos que se pidieron (PSE + Nequi "y demás bancos") sin que
la cuenta bancaria propia del comercio sea Bancolombia.
**Decisión:** PayU como proveedor de pago (negocio) se mantiene. La vía
técnica cambió el 2026-08-29 — ver nota de implementación al final de
este documento: ningún plugin comunitario de Medusa sirve para PayU
LatAm, hace falta un módulo de pago custom. Wompi queda descartado por
el requisito de registro Bancolombia/Nequi, no por limitación técnica.
**Pendiente antes de implementar (no resoluble en este ADR):**
- Registro real de comercio en PayU (persona natural o jurídica —
jurídica pide NIT, representante legal y estados financieros
básicos) y credenciales reales (`apiLogin`, `apiKey`, `merchantId`,
`accountId` — terminología real de la API de PayU LatAm, ver nota de
implementación) — nunca generadas ni asumidas por mí.
- Escribir el módulo de pago custom para PayU LatAm (no instalar un
plugin — no existe ninguno real para esta API, ver nota de
implementación).
## Opciones consideradas
| Opción | A favor | En contra |
|---|---|---|
| **Stripe** | Integración core de Medusa, mejor documentada, configuración mínima | No acepta comercios registrados en Colombia — bloqueador de negocio, no técnico |
| **PayU** — **elegido** (proveedor de negocio; integración técnica ahora es custom, ver nota de implementación) | Solo exige cuenta bancaria colombiana a nombre del comercio, sin banco específico (encaja con Davivienda/Lulo Bank/Banco de Bogotá); soporta PSE + Nequi + Daviplata para el cliente sin importar el banco del comercio | Ningún plugin de Medusa real sirve para su API LatAm (`apiLogin`/`apiKey`/`accountId`) — los dos paquetes que parecían encajar por nombre son en realidad para PayU Polonia/CEE (`clientId`/`posId`/`secondKey`), uno de ellos archivado; requiere módulo de pago custom |
| **Wompi** (`medusa-payment-wompi-col`) | Liquidación directa en COP vía Bancolombia | Exige cuenta Bancolombia o, como mínimo, Nequi solo para **registrarse** como comercio — ninguno de los bancos disponibles (Davivienda/Lulo Bank/Banco de Bogotá) califica sin abrir una cuenta nueva; además, sin versión de Medusa documentada ni actividad reciente visible en el plugin |
| **Seguir con WhatsApp** (no pago en línea) | Cero riesgo nuevo, cero dependencia externa | No resuelve el objetivo real (transacción completa sin intervención manual); no es checkout, es lo que ya existe hoy |
## Consecuencias
- Las shipping options son un prerequisito independiente del proveedor
y deben configurarse primero — sin ellas ningún flujo de checkout
completa, sea cual sea el pago.
- El pago de PayU se implementa como módulo custom (`AbstractPaymentProvider`
de Medusa) contra la API real de PayU LatAm, no como instalación de
un plugin de terceros — más superficie propia para mantener, mismo
patrón de esfuerzo que ya se identificó para EnvíoClick en §2.
- Los webhooks de pago (a definir en el módulo custom, apuntando a la
URL de notificación real de PayU LatAm) deben validarse en vivo
contra la cadena de red real del lab antes de confiar en el flujo,
dado el precedente de `incidente-cloudflared-504-quic-2026-08.md`.
- Se acepta como deuda: si en el futuro se abre una cuenta Bancolombia
o Nequi, Wompi (liquidación más directa en COP) vuelve a ser una
opción válida a reevaluar — no se descarta por limitación técnica,
solo por el requisito de registro actual.
- Revertir esta decisión implica deshabilitar el provider en la región
y volver al botón de WhatsApp — el carrito y el catálogo no cambian
de forma, solo el paso final.
## Nota de implementación (2026-08-29): shipping options creadas
Bloqueador de §1 resuelto en producción, verificado extremo a extremo
contra el Store API real (no solo creado en Admin):
- Stock location "Bodega principal ARI Shopping" (`sloc_01M15G3D1336V8WQW3VZQRP6AN`).
- Fulfillment set + zona de servicio "Colombia" (`geo_zone` tipo país, `co`).
- Provider `manual_manual` habilitado en la ubicación.
- Shipping option "Envío nacional estándar", tarifa fija $15.000 COP
(placeholder de negocio dentro del rango real de mercado
$12.000–$18.000 que dio el usuario, no un cálculo por peso/volumen —
eso es justamente lo que EnvíoClick resolvería más adelante),
vinculada al Default Shipping Profile y a la región Colombia/COP.
- **Paso no documentado en ningún lado y que rompía todo en silencio:**
la stock location tiene que estar vinculada al Sales Channel
(`/admin/stock-locations/{id}/sales-channels`) para que el Store API
devuelva la shipping option — sin esto, `GET /store/shipping-options`
devolvía `[]` vacío pese a que el fulfillment set, la zona de
servicio y el provider ya estaban bien configurados. Confirmado con
un carrito de prueba real (dirección `co` + `cart_id`) antes y
después del vínculo.
## Nota de implementación 2 (2026-08-29): corrección — no existe plugin real para PayU LatAm
Antes de instalar nada se verificó el código/README real de los dos
únicos paquetes de Medusa que aparecen buscando "PayU" — misma
disciplina que ya aplicó ADR 0006 al plugin de Meilisearch, esta vez
con un resultado distinto: **ninguno de los dos sirve.**
- `@tax1driver/medusa-payu`: opciones de configuración `clientId`,
`clientSecret`, `merchantPosId`, `secondKey` — terminología exacta
de la API de **PayU Polonia/CEE** (OAuth + `posId`), no de PayU
LatAm.
- `medusa-plugin-payu` (`rigby-sh/medusa-plugin-payu`): mismo problema
(`clientId`, `posId`, sandbox en `secure.snd.payu.com`, dominio de
PayU Europa) y además **archivado desde 2025-03-21** — sin
mantenimiento.
- La API real que se necesita, PayU LatAm
(`developers.payulatam.com`, la que cubre Colombia junto con
Argentina/Brasil/Chile/México/Panamá/Perú), autentica con
`apiLogin`/`apiKey` en el objeto merchant y `accountId` en el
objeto order — un modelo de autenticación distinto, no solo nombres
de campo distintos.
"PayU" es una marca compartida por dos productos/API históricamente
separados (LatAm vs. Europa/CEE); un paquete que dice "PayU" a secas
sin especificar LatAm es, con alta probabilidad, para el producto
europeo. **Consecuencia:** la integración de pago se implementa como
módulo custom de Medusa (`AbstractPaymentProvider`) contra
`developers.payulatam.com` directamente — mismo tipo de esfuerzo que
ya se había anticipado para EnvíoClick en §2, no una instalación de
plugin como asumía la decisión original de este ADR.
@@ -0,0 +1,451 @@
# 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.
22. **Importación manual de imágenes no se refleja en el storefront**
(reportado por el usuario 2026-09-20): subir/editar imágenes de un
producto a mano (vs. importación por CSV, que sí funciona) no
aparece del lado del storefront. Falta reproducir contra el flujo
real de importación manual del admin de Medusa (ver
`temp-admin`/`ari-shopping-tienda` en memoria) y confirmar si es
caché (ISR de Next, `force-dynamic` ya está en la página de
producto — ver más arriba), staleness de Meilisearch (ver memoria
`search-inventory-staleness`, mismo síntoma de "el catálogo directo
sí, la búsqueda no") o algo específico del pipeline de importación
en `commerce-backend`.
23. **En mobile, la visualización de imágenes de producto se corta y
obliga a scrollear** (reportado por el usuario 2026-09-20, con
captura real de `shop.cruzcloud.net` en un producto con múltiples
fotos). Toda la clase de bug de overflow por falta de `min-w-0` ya
se revisó en vivo en esta sesión y sigue presente en
`product-gallery.tsx` y `app/product/[slug]/page.tsx` — sospecha
sin confirmar: el modal "Zoom interactivo"
(`ProductGallery`/`DialogContent`, `min-h-[70vh]` +
`overflow-hidden`) en viewport móvil real, donde la barra de
direcciones de Chrome/Safari hace que `100vh` mida más que el área
visible real. Pendiente reproducir con un dispositivo real o
emulación de `visualViewport` (Playwright con viewport móvil no
replica el comportamiento dinámico de la barra de direcciones).
@@ -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.
+3
View File
@@ -96,6 +96,9 @@ nav:
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md
- "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.188
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.269
# 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.271
env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION
value: v1.0.271
- 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
@@ -0,0 +1,14 @@
import { setShippingAddress } from "@/lib/headless/providers/medusa-cart";
export async function POST(request: Request) {
const { cartId, ...address } = await request.json();
if (!cartId || !address.firstName || !address.address1 || !address.city || !address.phone || !address.email) {
return Response.json({ error: "Faltan campos obligatorios de la dirección" }, { status: 400 });
}
try {
const cart = await setShippingAddress(cartId, address);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { completeCart } from "@/lib/headless/providers/medusa-payment";
export async function POST(request: Request) {
const { cartId } = await request.json();
if (!cartId) {
return Response.json({ error: "Falta cartId" }, { status: 400 });
}
try {
await completeCart(cartId);
return Response.json({ ok: true });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,31 @@
import { removeLineItem, updateLineItemQuantity } from "@/lib/headless/providers/medusa-cart";
interface RouteParams { params: Promise<{ lineItemId: string }> }
export async function POST(request: Request, { params }: RouteParams) {
const { lineItemId } = await params;
const { cartId, quantity } = await request.json();
if (!cartId || typeof quantity !== "number") {
return Response.json({ error: "cartId y quantity son obligatorios" }, { status: 400 });
}
try {
const cart = await updateLineItemQuantity(cartId, lineItemId, quantity);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
export async function DELETE(request: Request, { params }: RouteParams) {
const { lineItemId } = await params;
const cartId = new URL(request.url).searchParams.get("cartId");
if (!cartId) {
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
}
try {
const cart = await removeLineItem(cartId, lineItemId);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { addLineItem } from "@/lib/headless/providers/medusa-cart";
export async function POST(request: Request) {
const { cartId, variantId, quantity } = await request.json();
if (!cartId || !variantId) {
return Response.json({ error: "cartId y variantId son obligatorios" }, { status: 400 });
}
try {
const cart = await addLineItem(cartId, variantId, quantity ?? 1);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,10 @@
import { getPseBanks } from "@/lib/headless/providers/medusa-payment";
export async function GET() {
try {
const banks = await getPseBanks();
return Response.json({ banks });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,15 @@
import { createPaymentCollection, createPsePaymentSession } from "@/lib/headless/providers/medusa-payment";
export async function POST(request: Request) {
const { cartId, ...pse } = await request.json();
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.firstName || !pse.lastName || !pse.email || !pse.phone) {
return Response.json({ error: "Faltan campos obligatorios para el pago con PSE" }, { status: 400 });
}
try {
const paymentCollectionId = await createPaymentCollection(cartId);
const bankUrl = await createPsePaymentSession(paymentCollectionId, pse);
return Response.json({ bankUrl });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { createCart, getCart } from "@/lib/headless/providers/medusa-cart";
// cart-store.ts es "use client" y no puede pegarle directo a Medusa
// (MEDUSA_BACKEND_URL es DNS interno del cluster, no resuelve desde el
// navegador) -- mismo motivo que /api/search. Nunca cachear: es
// estado mutable por sesion, no catalogo.
export async function POST() {
try {
const cart = await createCart();
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
export async function GET(request: Request) {
const cartId = new URL(request.url).searchParams.get("cartId");
if (!cartId) {
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
}
try {
const cart = await getCart(cartId);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { addShippingMethod } from "@/lib/headless/providers/medusa-cart";
export async function POST(request: Request) {
const { cartId, optionId } = await request.json();
if (!cartId || !optionId) {
return Response.json({ error: "cartId y optionId son obligatorios" }, { status: 400 });
}
try {
const cart = await addShippingMethod(cartId, optionId);
return Response.json(cart);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { getShippingOptions } from "@/lib/headless/providers/medusa-cart";
export async function GET(request: Request) {
const cartId = new URL(request.url).searchParams.get("cartId");
if (!cartId) {
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
}
try {
const options = await getShippingOptions(cartId);
return Response.json({ options });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 502 });
}
}
@@ -0,0 +1,14 @@
import { handleGoogleCallback } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { code, state } = await request.json();
if (!code || !state) {
return Response.json({ error: "Faltan parámetros de Google" }, { status: 400 });
}
try {
const customer = await handleGoogleCallback(code, state);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,14 @@
import { getGoogleAuthUrl } from "@/lib/headless/providers/medusa-customer";
// Navegacion de pagina completa (no fetch/XHR) -- el boton "Continuar
// con Google" enlaza directo aca, el browser sigue el 302 hasta la
// pantalla real de consentimiento de Google.
export async function GET(request: Request) {
try {
const location = await getGoogleAuthUrl();
return Response.redirect(location);
} catch (error) {
const message = encodeURIComponent((error as Error).message);
return Response.redirect(new URL(`/?googleAuthError=${message}`, request.url));
}
}
@@ -0,0 +1,14 @@
import { loginCustomer } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { email, password } = await request.json();
if (!email || !password) {
return Response.json({ error: "Correo y contraseña son obligatorios" }, { status: 400 });
}
try {
const customer = await loginCustomer(email, password);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,14 @@
import { getCurrentCustomer } from "@/lib/headless/providers/medusa-customer";
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return Response.json({ error: "token es obligatorio" }, { status: 400 });
}
try {
const customer = await getCurrentCustomer(token);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 401 });
}
}
@@ -0,0 +1,14 @@
import { registerCustomer } from "@/lib/headless/providers/medusa-customer";
export async function POST(request: Request) {
const { email, password, firstName, lastName } = await request.json();
if (!email || !password || !firstName || !lastName) {
return Response.json({ error: "Faltan campos obligatorios" }, { status: 400 });
}
try {
const customer = await registerCustomer(email, password, firstName, lastName);
return Response.json(customer);
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { GoogleCallbackClient } from "@/components/account/google-callback-client";
export default function GoogleCallbackPage() {
return (
<Suspense>
<GoogleCallbackClient />
</Suspense>
);
}
+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>
);
}
@@ -0,0 +1,5 @@
import { CheckoutClient } from "@/components/checkout/checkout-client";
export default function CheckoutPage() {
return <CheckoutClient />;
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { PseReturnClient } from "@/components/checkout/pse-return-client";
export default function PseReturnPage() {
return (
<Suspense fallback={null}>
<PseReturnClient />
</Suspense>
);
}
+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>
);
}
+31 -1
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { CategoryTiles } from "@/components/home/category-tiles";
@@ -17,5 +18,34 @@ 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><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
// 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
de promo-backpacks.webp. */}
<div className="relative min-h-64 overflow-hidden bg-ari-coral-claro">
<div className="absolute inset-0 [background-image:radial-gradient(rgba(0,85,184,0.35)_1.5px,transparent_1.5px)] [background-size:18px_18px]" />
<div className="absolute -bottom-12 -left-12 size-52 rounded-full bg-ari-marino" />
<div className="absolute inset-0 flex items-center justify-center gap-3 p-6">
<div className="relative size-28 -rotate-6 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:size-32">
<Image src="/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" alt="" fill sizes="128px" className="object-cover" />
</div>
<div className="relative size-28 rotate-3 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:size-32">
<Image src="/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" alt="" fill sizes="128px" className="object-cover" />
</div>
<div className="relative hidden size-28 -rotate-3 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:block sm:size-32">
<Image src="/images/products/ari-009-mochilas-disney-coleccion-marvel.webp" alt="" fill sizes="128px" className="object-cover" />
</div>
</div>
</div></div></section></>;
}
@@ -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>
</>
);
}
@@ -24,3 +24,28 @@ data:
# env var del plugin, no del servidor Meilisearch en si -- ver
# MEILI_MASTER_KEY en secrets.template.yaml).
MEILISEARCH_HOST: http://meilisearch-svc:7700
# ePayco (ADR 0009 -- tercer proveedor evaluado, ver medusa-config.ts
# para el historial completo) -- solo URLs/config no sensible,
# credenciales reales (publicKey/privateKey/pCustIdCliente/pKey) van en
# secrets.template.yaml. EPAYCO_ENV=sandbox hasta que haya credenciales
# de produccion reales.
EPAYCO_ENV: sandbox
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
# EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen,
# 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 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: "76001000"
ENVIOCLICK_ORIGIN_ADDRESS: "Cra 100 #42-65"
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
ENVIOCLICK_ORIGIN_EMAIL: "[email protected]"
ENVIOCLICK_ORIGIN_PHONE: "3014750823"
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
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.168
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -35,6 +35,22 @@ stringData:
# commerce-storefront: el subscriber de Medusa lo envía como header
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
REVALIDATE_SECRET: CAMBIAR
# ePayco (ADR 0009 -- tercer proveedor evaluado: PayU LatAm quedó
# inviable porque Rapyd compró ese negocio en 2023, Rapyd descartado
# por costo; ePayco es de Davivienda desde 2024, mejor tarifa PSE para
# el volumen real del usuario). Credenciales del comercio real, nunca
# hardcodeadas acá. p_cust_id_cliente/p_key son valores propios del
# dashboard de ePayco (Configuración > Llaves secretas), distintos de
# publicKey/privateKey.
EPAYCO_PUBLIC_KEY: CAMBIAR
EPAYCO_PRIVATE_KEY: CAMBIAR
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
EPAYCO_P_KEY: CAMBIAR
# EnvíoClick (ADR 0009 §2) -- API key real de la cuenta
# (envioclickpro.com.co > API Key / Desarrolladores).
ENVIOCLICK_API_KEY: CAMBIAR
---
apiVersion: v1
kind: Secret
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { LogOut, MessageCircle, PackageCheck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useCustomerStore } from "@/store/customer-store";
type Mode = "login" | "register";
function GoogleIcon() {
return (
<svg viewBox="0 0 48 48" className="size-4" aria-hidden="true">
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.7-6.1 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21 21-9.4 21-21c0-1.4-.1-2.7-.4-3.5z" />
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.9 18.9 13 24 13c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 16.1 3 9.3 7.6 6.3 14.7z" />
<path fill="#4CAF50" d="M24 45c5.2 0 9.9-2 13.4-5.2l-6.2-5.2c-2 1.5-4.6 2.4-7.2 2.4-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.2 40.3 16 45 24 45z" />
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.2 4.3-4.1 5.6l6.2 5.2C41.4 35.9 45 30.5 45 24c0-1.4-.1-2.7-.4-3.5z" />
</svg>
);
}
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
const [mode, setMode] = useState<Mode>("login");
const [form, setForm] = useState({ email: "", password: "", firstName: "", lastName: "" });
async function submit(event: FormEvent) {
event.preventDefault();
const ok = mode === "login"
? await login(form.email, form.password)
: await register(form.email, form.password, form.firstName, form.lastName);
if (ok) setForm({ email: "", password: "", firstName: "", lastName: "" });
}
const isLoggedIn = hasHydrated && customer !== null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-7">
<div className="pr-10">
{isLoggedIn ? (
<>
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Tu cuenta</span>
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="outline"><Link href="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div>
</>
) : (
<>
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
<h2 className="mt-3 text-3xl font-black">{mode === "login" ? "Inicia sesión" : "Crea tu cuenta"}</h2>
<form onSubmit={submit} className="mt-5 space-y-3">
{mode === "register" && (
<div className="grid gap-3 sm:grid-cols-2">
<Input placeholder="Nombre" required value={form.firstName} onChange={(event) => setForm({ ...form, firstName: event.target.value })} />
<Input placeholder="Apellido" required value={form.lastName} onChange={(event) => setForm({ ...form, lastName: event.target.value })} />
</div>
)}
<Input type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
<Input type="password" placeholder="Contraseña" required minLength={8} value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
</Button>
</form>
<div className="mt-3 flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
<span className="h-px flex-1 bg-slate-100" />o<span className="h-px flex-1 bg-slate-100" />
</div>
<Button asChild variant="outline" size="lg" className="mt-3 w-full">
<a href="/api/customer/google/start">
<GoogleIcon />Continuar con Google
</a>
</Button>
<button
type="button"
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
onClick={() => setMode(mode === "login" ? "register" : "login")}
>
{mode === "login" ? "¿No tienes cuenta? Regístrate" : "¿Ya tienes cuenta? Inicia sesión"}
</button>
<div className="mt-6 border-t border-slate-100 pt-5">
<Button asChild variant="outline" className="w-full">
<a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer">
<MessageCircle />Hablar con ARI
</a>
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,44 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCustomerStore } from "@/store/customer-store";
export function GoogleCallbackClient() {
const router = useRouter();
const searchParams = useSearchParams();
const loginWithGoogle = useCustomerStore((state) => state.loginWithGoogle);
const [error, setError] = useState<string | null>(null);
const ran = useRef(false);
useEffect(() => {
// Guard contra el doble-mount de StrictMode -- el code de Google es
// de un solo uso, un segundo intento con el mismo code fallaria.
if (ran.current) return;
ran.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
setError("Faltan parámetros de Google en la URL de retorno.");
return;
}
loginWithGoogle(code, state).then((ok) => {
router.replace(ok ? "/" : "/?googleAuthError=1");
});
}, [searchParams, loginWithGoogle, router]);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
{error ? (
<>
<p className="text-lg font-black text-rose-600">No se pudo iniciar sesión con Google</p>
<p className="text-sm text-slate-600">{error}</p>
</>
) : (
<p className="text-sm font-bold text-slate-600">Conectando con Google…</p>
)}
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
@@ -17,13 +18,6 @@ export function CartDrawer() {
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const checkout = () => {
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
const message = `Hola Ari Shopping 👋\nQuiero consultar disponibilidad para:\n\n${lines}\n\nNombre:\nCiudad:\nForma de entrega:`;
void navigator.clipboard?.writeText(message);
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
<SheetContent>
@@ -49,7 +43,15 @@ export function CartDrawer() {
<div className="space-y-4 border-t border-slate-100 p-6">
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
<Button variant="primary" size="lg" className="w-full" disabled={!items.length} onClick={checkout}>Continuar por WhatsApp</Button>
{/* Button asChild + disabled no funciona sobre un <a> (:disabled
es pseudo-clase de elementos de formulario, un Link nunca la
cumple) -- se ramifica en vez de confiar en el prop disabled
sobre un Link. */}
{items.length > 0 ? (
<Button asChild variant="primary" size="lg" className="w-full"><Link href="/checkout" onClick={closeCart}>Confirmar pedido</Link></Button>
) : (
<Button variant="primary" size="lg" className="w-full" disabled>Confirmar pedido</Button>
)}
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
</div>
</SheetContent>
@@ -352,7 +352,11 @@ export function CatalogClient({
</div>
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
<aside className="h-fit space-y-6 rounded-lg border border-slate-100 bg-white p-6 shadow-lg shadow-ari-marino/5 lg:sticky lg:top-44">
{/* overflow-y-auto + max-h propio en lg: sticky no le da scroll
propio al contenido, y con 30+ categorias mas marca/edad/precio
el panel se pasa del viewport sin forma de llegar al resto salvo
bajando el zoom del navegador (reportado en vivo). */}
<aside className="h-fit space-y-6 overflow-y-auto rounded-lg border border-slate-100 bg-white p-6 shadow-lg shadow-ari-marino/5 lg:sticky lg:top-44 lg:max-h-[calc(100vh-13rem)]">
<div className="flex items-center justify-between">
<strong className="flex items-center gap-2 text-lg font-black">
<SlidersHorizontal className="size-5 text-ari-coral-claro" />
@@ -0,0 +1,319 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
const SELECT_CLASS =
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
// El pago es ePayco/PSE (modulo custom del backend, ver
// workloads/commerce-backend/src/modules/epayco-pse) -- redirige al banco
// del comprador, la confirmacion real llega despues via webhook async
// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la
// iniciacion del pago falla, ya no como camino principal.
interface ShippingOption {
id: string;
name: string;
amount: number;
}
interface PseBank {
pseCode: string;
description: string;
}
type Step = "address" | "shipping" | "payment";
async function postJson(url: string, body: unknown) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
export function CheckoutClient() {
const { cartId, items, hasHydrated } = useCartStore();
const [step, setStep] = useState<Step>("address");
const [form, setForm] = useState({
firstName: "",
lastName: "",
address1: "",
// Se guarda el id DIVIPOLA seleccionado, no el texto libre -- ver
// lib/data/colombia.ts. El nombre real se deriva de este id para
// mandarlo a Medusa (que solo espera texto en shipping_address).
departmentId: null as number | null,
cityId: null as number | null,
phone: "",
email: "",
});
const availableCities = form.departmentId ? ciudadesPorDepartamento(form.departmentId) : [];
const selectedDepartamento = departamentos.find((departamento) => departamento.id === form.departmentId);
const selectedCiudad = availableCities.find((ciudad) => ciudad.id === form.cityId);
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([]);
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
const [shippingTotal, setShippingTotal] = useState(0);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [banks, setBanks] = useState<PseBank[]>([]);
const [bankCode, setBankCode] = useState("");
const [userType, setUserType] = useState<"0" | "1">("0");
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState("");
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const currency = items[0]?.product.currency;
async function submitAddress(event: FormEvent) {
event.preventDefault();
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
setLoading(true);
setError(null);
try {
const cart = await postJson("/api/cart/address", {
cartId,
firstName: form.firstName,
lastName: form.lastName,
address1: form.address1,
city: selectedCiudad.name,
province: selectedDepartamento.name,
// ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los
// necesita para cotizar sin ambiguedad, el nombre solo no
// alcanza (hay municipios con el mismo nombre en distintos
// departamentos).
cityId: selectedCiudad.id,
departmentId: selectedDepartamento.id,
phone: form.phone,
email: form.email,
});
if (cart.error) throw new Error(cart.error);
const response = await fetch(`/api/cart/shipping-options?cartId=${cartId}`);
const data = await response.json();
const options: ShippingOption[] = data.options ?? [];
setShippingOptions(options);
setSelectedOptionId(options[0]?.id ?? null);
setStep("shipping");
} catch (submitError) {
setError((submitError as Error).message);
} finally {
setLoading(false);
}
}
async function submitShipping() {
if (!cartId || !selectedOptionId) return;
setLoading(true);
setError(null);
try {
const cart = await postJson("/api/cart/shipping-method", { cartId, optionId: selectedOptionId });
if (cart.error) throw new Error(cart.error);
setShippingTotal(cart.shippingTotal ?? 0);
setTotal(cart.total ?? itemsSubtotal);
const banksResponse = await fetch("/api/cart/payment/banks");
const banksData = await banksResponse.json();
const bankList: PseBank[] = banksData.banks ?? [];
setBanks(bankList);
setBankCode(bankList[0]?.pseCode ?? "");
setStep("payment");
} catch (submitError) {
setError((submitError as Error).message);
} finally {
setLoading(false);
}
}
async function submitPayment(event: FormEvent) {
event.preventDefault();
if (!cartId || !bankCode || !docNumber || !selectedCiudad || !selectedDepartamento) return;
setLoading(true);
setError(null);
try {
const session = await postJson("/api/cart/payment/session", {
cartId,
bankCode,
userType,
docType,
docNumber,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
city: selectedCiudad.name,
});
if (session.error) throw new Error(session.error);
const completion = await postJson("/api/cart/complete", { cartId });
if (completion.error) throw new Error(completion.error);
// Navegacion completa, no iframe -- asi lo exige ePayco para el
// flujo de PSE. La confirmacion real llega despues via webhook,
// esta pagina nunca vuelve a cargar sola.
window.location.href = session.bankUrl;
} catch (submitError) {
setError((submitError as Error).message);
setLoading(false);
}
}
function continueOnWhatsApp() {
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
const message = `Hola Ari Shopping 👋\nQuiero confirmar mi pedido:\n\n${lines}\n\nSubtotal: ${formatMoney(itemsSubtotal, currency)}\nEnvío: ${formatMoney(shippingTotal, currency)}\nTotal: ${formatMoney(total, currency)}\n\nNombre: ${form.firstName} ${form.lastName}\nDirección: ${form.address1}, ${selectedCiudad?.name ?? ""}${selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}\nTeléfono: ${form.phone}`;
void navigator.clipboard?.writeText(message);
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
}
if (!hasHydrated) return null;
if (items.length === 0) {
return (
<div className="mx-auto max-w-lg px-4 py-24 text-center">
<p className="text-lg font-bold">Tu carrito está vacío.</p>
<Button asChild variant="primary" className="mt-6"><Link href="/catalog">Ver catálogo</Link></Button>
</div>
);
}
return (
<div className="mx-auto grid max-w-5xl gap-10 px-4 py-10 lg:grid-cols-[1fr_.8fr] lg:py-16">
<div>
<Link href="/" className="mb-6 inline-flex items-center gap-1.5 text-sm font-bold text-slate-500 hover:text-ari-marino"><ArrowLeft className="size-4" />Seguir comprando</Link>
<h1 className="text-3xl font-black tracking-tight sm:text-4xl">Confirmar pedido</h1>
{step === "address" && (
<form onSubmit={submitAddress} className="mt-8 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Input placeholder="Nombre" required value={form.firstName} onChange={(event) => setForm({ ...form, firstName: event.target.value })} />
<Input placeholder="Apellido" required value={form.lastName} onChange={(event) => setForm({ ...form, lastName: event.target.value })} />
</div>
<Input placeholder="Dirección" required value={form.address1} onChange={(event) => setForm({ ...form, address1: event.target.value })} />
<div className="grid gap-4 sm:grid-cols-2">
<select
required
value={form.departmentId ?? ""}
onChange={(event) => setForm({ ...form, departmentId: Number(event.target.value) || null, cityId: null })}
className={SELECT_CLASS}
>
<option value="" disabled>Departamento</option>
{departamentos.map((departamento) => <option key={departamento.id} value={departamento.id}>{departamento.name}</option>)}
</select>
<select
required
disabled={!form.departmentId}
value={form.cityId ?? ""}
onChange={(event) => setForm({ ...form, cityId: Number(event.target.value) || null })}
className={SELECT_CLASS}
>
<option value="" disabled>Ciudad</option>
{availableCities.map((ciudad) => <option key={ciudad.id} value={ciudad.id}>{ciudad.name}</option>)}
</select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Input placeholder="Teléfono" required value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
<Input type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
</div>
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>{loading ? "Guardando…" : "Continuar a envío"}</Button>
</form>
)}
{step === "shipping" && (
<div className="mt-8 space-y-4">
<p className="flex items-center gap-2 text-sm font-bold text-slate-500"><MapPin className="size-4" />{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
<div className="space-y-3">
{shippingOptions.map((option) => (
<label key={option.id} className={`flex cursor-pointer items-center justify-between rounded-2xl border-2 p-4 transition ${selectedOptionId === option.id ? "border-ari-marino bg-ari-marino/5" : "border-slate-200"}`}>
<span className="flex items-center gap-3 font-bold"><Truck className="size-5 text-ari-marino" />{option.name}</span>
<span className="flex items-center gap-3">
<span className="font-black">{formatMoney(option.amount, currency)}</span>
<input type="radio" name="shipping" checked={selectedOptionId === option.id} onChange={() => setSelectedOptionId(option.id)} />
</span>
</label>
))}
{shippingOptions.length === 0 && <p className="text-sm text-slate-500">No hay opciones de envío disponibles para esta dirección.</p>}
</div>
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
<Button variant="primary" size="lg" className="w-full" disabled={loading || !selectedOptionId} onClick={submitShipping}>{loading ? "Guardando…" : "Continuar"}</Button>
</div>
)}
{step === "payment" && (
<form onSubmit={submitPayment} className="mt-8 space-y-4">
<div className="rounded-2xl border border-slate-200 p-5">
<p className="text-xs font-black uppercase tracking-wide text-slate-400">Entrega</p>
<p className="mt-1 font-bold">{form.firstName} {form.lastName}</p>
<p className="text-sm text-slate-500">{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
<p className="text-sm text-slate-500">{form.phone}</p>
</div>
<p className="flex items-center gap-2 text-sm font-bold text-slate-700"><Landmark className="size-4" />Paga con PSE (transferencia desde tu banco)</p>
{banks.length > 0 ? (
<select required value={bankCode} onChange={(event) => setBankCode(event.target.value)} className={SELECT_CLASS}>
{banks.map((bank) => <option key={bank.pseCode} value={bank.pseCode}>{bank.description}</option>)}
</select>
) : (
<p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">No pudimos cargar la lista de bancos — probá de nuevo en un momento, o confirmá tu pedido por WhatsApp abajo.</p>
)}
<div className="flex gap-4 text-sm font-bold">
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "0"} onChange={() => setUserType("0")} />Persona natural</label>
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "1"} onChange={() => setUserType("1")} />Persona jurídica</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<select
required
value={docType}
onChange={(event) => setDocType(event.target.value as typeof docType)}
className={SELECT_CLASS}
>
<option value="CC">Cédula de ciudadanía</option>
<option value="CE">Cédula de extranjería</option>
<option value="TI">Tarjeta de identidad</option>
<option value="PP">Pasaporte</option>
<option value="NIT">NIT</option>
</select>
<Input placeholder="Número de documento" required value={docNumber} onChange={(event) => setDocNumber(event.target.value)} />
</div>
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading || banks.length === 0}>{loading ? "Redirigiendo a tu banco…" : "Pagar con PSE"}</Button>
<div className="flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
<span className="h-px flex-1 bg-slate-200" />¿Prefieres otra vía?<span className="h-px flex-1 bg-slate-200" />
</div>
<Button type="button" variant="outline" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
</form>
)}
</div>
<div className="h-fit rounded-3xl border border-slate-200 p-6">
<h2 className="font-black">Tu pedido</h2>
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.lineItemId} className="flex items-center gap-3">
<div className="relative size-14 shrink-0 overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.thumbnail} alt={item.product.name} fill sizes="56px" className="object-cover" /></div>
<div className="flex-1"><p className="text-sm font-bold leading-5">{item.product.name}</p><p className="text-xs text-slate-500">Cantidad: {item.quantity}</p></div>
<p className="text-sm font-black">{formatMoney((item.product.price ?? 0) * item.quantity, item.product.currency)}</p>
</div>
))}
</div>
<div className="mt-5 space-y-2 border-t border-slate-100 pt-4 text-sm">
<div className="flex justify-between"><span className="text-slate-500">Subtotal</span><span className="font-bold">{formatMoney(itemsSubtotal, currency)}</span></div>
{step === "payment" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "payment" ? total : itemsSubtotal, currency)}</span></div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { Clock } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
// ePayco redirige aca despues de que el comprador paga en su banco --
// esto NO confirma el pago. Los parametros de esta URL son solo
// informativos; el estado real y definitivo llega por el webhook async
// de ePayco al backend (getWebhookActionAndData en el modulo
// epayco-pse), nunca por esta pantalla. Por eso el mensaje nunca afirma
// exito, solo que estamos esperando la confirmacion.
export function PseReturnClient() {
const searchParams = useSearchParams();
const referenceCode = searchParams.get("referenceCode") ?? searchParams.get("reference_sale");
return (
<div className="mx-auto max-w-lg px-4 py-24 text-center">
<Clock className="mx-auto size-12 text-ari-marino" />
<h1 className="mt-6 text-2xl font-black">Estamos confirmando tu pago</h1>
<p className="mt-3 text-slate-500">
Tu banco ya procesó la transferencia. ePayco nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
{referenceCode && (
<>
{" "}Tu número de referencia es <strong>{referenceCode}</strong>.
</>
)}
</p>
<p className="mt-3 text-sm text-slate-400">
Si después de un rato no ves la confirmación, escribinos por WhatsApp con este número de referencia.
</p>
<Button asChild variant="primary" className="mt-8">
<Link href="/catalog">Volver al catálogo</Link>
</Button>
</div>
);
}
@@ -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,51 @@
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} />{/* min-w-0: sin esto, un grid item de texto toma como minimo su ancho
max-content (una sola linea, "Recibe lanzamientos y promociones
especiales." sin wrappear, ~335px) en vez de dejarse achicar y
wrappear dentro del track del grid -- confirmado en vivo 2026-09-20
contra un viewport real de 320px: la columna entera del footer (y
con ella, la pagina completa) quedaba forzada a 335px de ancho por
este unico parrafo. Mismo bug de fondo que el ya documentado en
product-gallery.tsx/app/product/[slug]/page.tsx, en un grid en vez
de un overflow-x-auto. */}
<div className="min-w-0"><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-ari-coral px-4 text-sm font-black text-white">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless {appVersion} · GitOps</div></footer>;
}
function FooterColumn({ title, links }: { title: string; links: 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>;
}
@@ -2,14 +2,15 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, LayoutGrid, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store";
import { useCustomerStore } from "@/store/customer-store";
import { useWishlistStore } from "@/store/wishlist-store";
type CategoryNode = Category & { children: Category[] };
@@ -34,6 +35,9 @@ export function Navbar({ products, categories }: { products: Product[]; categori
const openWishlist = useWishlistStore((state) => state.openWishlist);
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
const customer = useCustomerStore((state) => state.customer);
const customerHydrated = useCustomerStore((state) => state.hasHydrated);
const isLoggedIn = customerHydrated && customer !== null;
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones):
@@ -51,26 +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">
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></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>
@@ -108,19 +133,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</SheetContent>
</Sheet>
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
<DialogContent className="max-w-lg p-7">
<div className="pr-10">
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
<h2 className="mt-3 text-3xl font-black">Tu cuenta ARI</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="primary" size="lg"><a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer"><MessageCircle />Hablar con ARI</a></Button>
<Button asChild variant="outline"><Link href="/catalog" onClick={() => setAccountOpen(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
</div>
</div>
</DialogContent>
</Dialog>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</header>
);
}
@@ -199,6 +212,24 @@ function CategoryNavBar({
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
// Delay corto en vez de cerrar al toque: aunque el gap entre el boton y
// el panel ya se elimino (antes era la causa real -- el panel es
// absolute y no suma a la altura del contenedor, asi que un mt-1 ahi
// dejaba una franja muerta que cerraba el menu a mitad de camino,
// reportado en vivo), este margen extra tolera movimientos rapidos o en
// diagonal cerca del borde sin reabrir nada raro.
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 200);
};
useEffect(() => {
if (!open) return;
@@ -216,8 +247,15 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
};
}, [open]);
useEffect(() => () => cancelClose(), []);
const openNow = () => {
cancelClose();
setOpen(true);
};
return (
<div ref={containerRef} className="shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
@@ -231,7 +269,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
<div className="absolute left-0 top-full z-50 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
{tree.map((root) => (
<div key={root.id}>
@@ -1,11 +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);
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" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
}
@@ -24,7 +24,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
const lowStock =
!outOfStock &&
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
@@ -36,7 +38,8 @@ export function ProductCard({ product, priority = false }: { product: Product; p
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
<div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
<div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
</div>
<div className="flex flex-1 flex-col space-y-3 p-5">
@@ -67,12 +70,15 @@ export function ProductCard({ product, priority = false }: { product: Product; p
{lowStock !== null && (
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
)}
{outOfStock && (
<span className="flex items-center gap-1.5 text-xs font-bold text-rose-600"><TriangleAlert className="size-3.5" />Sin stock</span>
)}
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
<div className="flex flex-col">
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
<strong className="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong>
</div>
<Button variant="primary" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
<Button variant="primary" size="sm" disabled={outOfStock} onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />{outOfStock ? "Agotado" : "Agregar"}</Button>
</div>
</div>
</article>
@@ -2,19 +2,90 @@
import Image from "next/image";
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
import { PointerEvent, WheelEvent, useRef, useState } from "react";
import { MouseEvent, PointerEvent, WheelEvent, useRef, useState } from "react";
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 HoverZoomState {
lensLeft: number;
lensTop: number;
lensSize: number;
bgX: number;
bgY: number;
showPanel: boolean;
panelLeft: number;
panelTop: number;
panelSize: number;
}
// 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<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({
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,
});
};
const applyScale = (next: number) => {
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
setScale(value);
@@ -38,22 +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 overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
<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>
@@ -65,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 } 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";
@@ -11,12 +12,86 @@ export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem);
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false;
const lowStock =
!outOfStock &&
selectedVariant?.inventoryQuantity !== null &&
selectedVariant?.inventoryQuantity !== undefined &&
selectedVariant.inventoryQuantity > 0 &&
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
? selectedVariant.inventoryQuantity
: null;
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><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. */}<Button variant="primary" size="lg" className="hidden w-full lg:inline-flex" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
return (
<div>
<div className="mb-5 flex flex-wrap gap-2">
{product.variants.map((variant) => (
<button
key={variant.id}
onClick={() => setVariantId(variant.id)}
className={`rounded-full border px-4 py-2 text-sm font-bold ${
variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"
} ${variant.available === false ? "opacity-40" : ""}`}
>
{variant.value}
</button>
))}
</div>
<div className="mb-6 flex items-end gap-3">
<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>
{/* 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="w-full"
disabled={outOfStock}
onClick={() => addItem(product, variantId)}
>
<ShoppingBag />
{outOfStock ? "Agotado" : "Añadir al carrito"}
</Button>
<div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">
{outOfStock ? (
<span className="flex items-center gap-2 font-bold text-rose-600">
<XCircle className="size-4" />
Agotado
</span>
) : lowStock !== null ? (
<span className="flex items-center gap-2 font-bold text-amber-600">
<TriangleAlert className="size-4" />
Quedan {lowStock} unidades
</span>
) : (
<span className="flex items-center gap-2 font-bold">
<CheckCircle2 className="size-4 text-emerald-500" />
Disponibilidad por confirmar
</span>
)}
<span className="flex items-center gap-2 font-bold">
<Truck className="size-4 text-cyan-600" />
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.202
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.271
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.271
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
{
"data": [
{ "id": 5, "name": "Antioquia" },
{ "id": 8, "name": "Atlántico" },
{ "id": 11, "name": "Bogotá D.C." },
{ "id": 13, "name": "Bolívar" },
{ "id": 15, "name": "Boyacá" },
{ "id": 17, "name": "Caldas" },
{ "id": 18, "name": "Caquetá" },
{ "id": 19, "name": "Cauca" },
{ "id": 20, "name": "Cesar" },
{ "id": 23, "name": "Córdoba" },
{ "id": 25, "name": "Cundinamarca" },
{ "id": 27, "name": "Chocó" },
{ "id": 41, "name": "Huila" },
{ "id": 44, "name": "La Guajira" },
{ "id": 47, "name": "Magdalena" },
{ "id": 50, "name": "Meta" },
{ "id": 52, "name": "Nariño" },
{ "id": 54, "name": "Norte de Santander" },
{ "id": 63, "name": "Quindío" },
{ "id": 66, "name": "Risaralda" },
{ "id": 68, "name": "Santander" },
{ "id": 70, "name": "Sucre" },
{ "id": 73, "name": "Tolima" },
{ "id": 76, "name": "Valle del Cauca" },
{ "id": 81, "name": "Arauca" },
{ "id": 85, "name": "Casanare" },
{ "id": 86, "name": "Putumayo" },
{
"id": 88,
"name": "Archipiélago de San Andrés, Providencia y Santa Catalina"
},
{ "id": 91, "name": "Amazonas" },
{ "id": 94, "name": "Guainía" },
{ "id": 95, "name": "Guaviare" },
{ "id": 97, "name": "Vaupés" },
{ "id": 99, "name": "Vichada" }
]
}
+32
View File
@@ -0,0 +1,32 @@
import departamentosData from "./co-departamentos.json";
import ciudadesData from "./co-ciudades.json";
// Dataset real de DIVIPOLA (Division Politico-Administrativa de Colombia,
// codigos oficiales del DANE) -- 33 departamentos + 1123 municipios,
// fuente: proyecto26/colombia (github.com/proyecto26/colombia), verificado
// contra la codificacion oficial antes de incorporarlo (ej. id 5001 =
// Medellin, departmentId 5 = Antioquia).
//
// Se usan los codigos (no solo los nombres) porque EnvioClick -- la
// integracion de envios real que reemplazara la tarifa fija de ADR 0009 --
// va a necesitar identificar la ciudad de forma inequivoca, y las
// transportadoras colombianas en general referencian ciudades por su
// codigo DIVIPOLA, no por texto libre.
export interface Departamento {
id: number;
name: string;
}
export interface Ciudad {
id: number;
name: string;
departmentId: number;
}
export const departamentos: Departamento[] = departamentosData.data;
export const ciudades: Ciudad[] = ciudadesData.data;
export function ciudadesPorDepartamento(departmentId: number): Ciudad[] {
return ciudades.filter((ciudad) => ciudad.departmentId === departmentId);
}
@@ -0,0 +1,256 @@
// ADR 0009: carrito real conectado a Medusa (Store API), en vez del
// estado 100% local que existia antes. Archivo separado de medusa.ts
// a proposito -- ese es de solo lectura (catalogo, cacheado con
// revalidate/tags), este es de escritura (carrito, siempre no-store,
// nunca debe cachearse).
//
// Formas de respuesta verificadas en vivo contra la API real
// (2026-08-29), no asumidas contra documentacion:
// - crear/agregar/actualizar linea: { cart: {...} }
// - eliminar linea: { id, object, deleted, parent: {...} } -- el
// carrito actualizado viene en "parent", no en "cart".
// - agregar el mismo variant_id dos veces hace merge de cantidad en la
// misma linea (confirmado con un carrito de prueba real), no crea
// una linea duplicada -- por eso addLineItem siempre manda
// quantity=1 sin revisar antes si ya existe.
export interface CartLineProduct {
id: string;
name: string;
thumbnail: string;
price: number | null;
currency: string;
}
export interface CartLine {
lineItemId: string;
product: CartLineProduct;
quantity: number;
variantId: string;
}
export interface CartSummary {
cartId: string;
currency: string;
subtotal: number;
shippingTotal: number;
total: number;
items: CartLine[];
}
export interface ShippingAddressInput {
firstName: string;
lastName: string;
address1: string;
city: string;
province?: string;
phone: string;
email: string;
// ids DIVIPOLA/DANE reales (lib/data/colombia.ts) -- se guardan en la
// metadata de la direccion porque Medusa no tiene un campo propio para
// esto. EnvioClick (fulfillment provider real, ver ADR 0009 §2) los
// necesita para cotizar/generar guia sin ambiguedad -- el nombre de
// ciudad solo no alcanza (hay nombres de municipio repetidos entre
// distintos departamentos en Colombia).
cityId?: number;
departmentId?: number;
}
export interface ShippingOption {
id: string;
name: string;
amount: number;
}
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
}
return value.replace(/\/$/, "");
}
function regionId(): string {
const value = process.env.MEDUSA_REGION_ID;
if (!value) {
throw new Error("MEDUSA_REGION_ID no está configurado.");
}
return value;
}
type MedusaLineItem = {
id: string;
product_id: string;
variant_id: string;
title?: string | null;
product_title?: string | null;
thumbnail?: string | null;
quantity: number;
unit_price?: number | null;
product?: { images?: Array<{ url?: string | null }> | null } | null;
};
type MedusaCart = {
id: string;
currency_code: string;
subtotal?: number | null;
shipping_total?: number | null;
total?: number | null;
items?: MedusaLineItem[] | null;
};
function mapCart(cart: MedusaCart): CartSummary {
return {
cartId: cart.id,
currency: cart.currency_code,
subtotal: cart.subtotal ?? 0,
shippingTotal: cart.shipping_total ?? 0,
total: cart.total ?? cart.subtotal ?? 0,
items: (cart.items ?? []).map((item) => ({
lineItemId: item.id,
variantId: item.variant_id,
quantity: item.quantity,
product: {
id: item.product_id,
name: item.product_title || item.title || "Producto",
// item.thumbnail viene null en la practica para todo el catalogo
// real (confirmado contra /store/products: product.thumbnail
// tambien es null ahi, incluso con fotos reales cargadas) -- el
// catalogo ya resuelve esto cayendo a images[0] (mapProduct() en
// medusa.ts), asi que la linea del carrito pide esa misma relacion
// explicita (no viene por defecto) y aplica el mismo fallback.
thumbnail: item.thumbnail || item.product?.images?.[0]?.url || "/images/brand/logo-ari.png",
price: typeof item.unit_price === "number" ? item.unit_price : null,
currency: cart.currency_code,
},
})),
};
}
// Exportado para medusa-payment.ts (mismo backend, mismo criterio de
// no-store/x-publishable-api-key -- evita duplicar el fetch helper).
export async function medusaCartFetch(
path: string,
init?: { method?: string; body?: unknown },
): Promise<Record<string, unknown>> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Medusa cart API ${response.status}: ${text.slice(0, 300)}`);
}
return response.json();
}
// *items.product.images no viene por defecto (verificado en vivo) y es
// lo unico que necesitamos ademas del set por defecto -- *items ya trae
// unit_price/product_title/variant_id/quantity sin pedirlos aparte.
const ITEMS_FIELDS = "fields=*items,*items.product.images";
export async function createCart(): Promise<CartSummary> {
const data = await medusaCartFetch("/store/carts", {
method: "POST",
body: { region_id: regionId() },
});
return mapCart(data.cart as MedusaCart);
}
export async function getCart(cartId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`);
return mapCart(data.cart as MedusaCart);
}
export async function addLineItem(
cartId: string,
variantId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items?${ITEMS_FIELDS}`, {
method: "POST",
body: { variant_id: variantId, quantity },
});
return mapCart(data.cart as MedusaCart);
}
export async function updateLineItemQuantity(
cartId: string,
lineItemId: string,
quantity: number,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
method: "POST",
body: { quantity },
});
return mapCart(data.cart as MedusaCart);
}
export async function removeLineItem(cartId: string, lineItemId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/line-items/${lineItemId}?${ITEMS_FIELDS}`, {
method: "DELETE",
});
// Forma distinta a las demas: el carrito actualizado viene en
// "parent", no en "cart" -- verificado en vivo, no documentado.
return mapCart(data.parent as MedusaCart);
}
export async function setShippingAddress(
cartId: string,
address: ShippingAddressInput,
): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}?${ITEMS_FIELDS}`, {
method: "POST",
body: {
email: address.email,
shipping_address: {
first_name: address.firstName,
last_name: address.lastName,
address_1: address.address1,
city: address.city,
province: address.province || undefined,
country_code: "co",
phone: address.phone,
metadata:
address.cityId || address.departmentId
? { cityId: address.cityId, departmentId: address.departmentId }
: undefined,
},
},
});
return mapCart(data.cart as MedusaCart);
}
type MedusaShippingOption = {
id: string;
name: string;
amount?: number | null;
calculated_price?: { calculated_amount?: number | null } | null;
};
export async function getShippingOptions(cartId: string): Promise<ShippingOption[]> {
const data = await medusaCartFetch(`/store/shipping-options?cart_id=${cartId}`);
const options = (data.shipping_options as MedusaShippingOption[] | undefined) ?? [];
return options.map((option) => ({
id: option.id,
name: option.name,
amount: option.calculated_price?.calculated_amount ?? option.amount ?? 0,
}));
}
export async function addShippingMethod(cartId: string, optionId: string): Promise<CartSummary> {
const data = await medusaCartFetch(`/store/carts/${cartId}/shipping-methods?${ITEMS_FIELDS}`, {
method: "POST",
body: { option_id: optionId },
});
return mapCart(data.cart as MedusaCart);
}
@@ -0,0 +1,167 @@
// Cuentas de cliente reales conectadas a Medusa (Store API), mismo patron
// que medusa-cart.ts -- server-side, nunca cacheado (auth es estado
// mutable por sesion, no catalogo).
//
// Flujo verificado en vivo (2026-08-29) antes de escribir este archivo,
// no asumido contra documentacion: registro (auth/customer/emailpass/
// register) da un token de registro; ese token crea el customer real
// (POST /store/customers); pero ese token de registro NO sirve como
// sesion del customer -- hay que loguear de nuevo para obtener el token
// de sesion real. Mensajes de error exactos confirmados: registro con
// correo repetido -> 401 "Identity with email already exists"; login con
// credenciales invalidas -> 401 "Invalid email or password".
export interface CustomerSummary {
token: string;
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
}
return value.replace(/\/$/, "");
}
async function medusaAuthFetch(
path: string,
init?: { method?: string; body?: unknown; token?: string },
): Promise<Record<string, unknown>> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
throw new Error(message);
}
return data;
}
type MedusaCustomer = {
id: string;
email: string;
first_name?: string | null;
last_name?: string | null;
};
export async function getCurrentCustomer(token: string): Promise<CustomerSummary> {
const data = await medusaAuthFetch("/store/customers/me", { token });
const customer = data.customer as MedusaCustomer;
return {
token,
id: customer.id,
email: customer.email,
firstName: customer.first_name ?? null,
lastName: customer.last_name ?? null,
};
}
export async function loginCustomer(email: string, password: string): Promise<CustomerSummary> {
let data: Record<string, unknown>;
try {
data = await medusaAuthFetch("/auth/customer/emailpass", {
method: "POST",
body: { email, password },
});
} catch (error) {
if ((error as Error).message === "Invalid email or password") {
throw new Error("Correo o contraseña incorrectos.");
}
throw error;
}
return getCurrentCustomer(data.token as string);
}
// Flujo Google verificado en vivo (2026-08-29) contra el pod real, no
// solo contra la doc: GET /auth/customer/google funciona igual que el
// POST documentado (sin body, no hace falta callback_url -- ya viene de
// GOOGLE_CALLBACK_URL en el modulo); la respuesta no trae Set-Cookie, o
// sea que el "state" que Google reenvia en el callback es autocontenido
// (firmado), no depende de sesion de servidor -- confirmado con un
// callback con code/state inventados: 401 "No state provided, or
// session expired" (mismo mensaje que con state ausente, no un error de
// sesion real). auth/token/refresh confirmado que exige Bearer valido
// (401 "Unauthorized" con uno invalido, no 404).
function decodeJwtPayload(token: string): Record<string, unknown> {
const payload = token.split(".")[1] ?? "";
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = Buffer.from(base64, "base64").toString("utf-8");
return JSON.parse(json || "{}");
}
export async function getGoogleAuthUrl(): Promise<string> {
const data = await medusaAuthFetch("/auth/customer/google");
return data.location as string;
}
export async function handleGoogleCallback(code: string, state: string): Promise<CustomerSummary> {
const query = new URLSearchParams({ code, state }).toString();
const data = await medusaAuthFetch(`/auth/customer/google/callback?${query}`, { method: "POST" });
const initialToken = data.token as string;
const payload = decodeJwtPayload(initialToken);
const actorId = payload.actor_id as string | undefined;
if (!actorId) {
// Primer login con Google -- todavia no existe el customer real.
const userMetadata = (payload.user_metadata as Record<string, unknown> | undefined) ?? {};
const email = userMetadata.email as string;
const firstName = (userMetadata.given_name as string) ?? (userMetadata.name as string) ?? email.split("@")[0];
const lastName = (userMetadata.family_name as string) ?? "";
await medusaAuthFetch("/store/customers", {
method: "POST",
token: initialToken,
body: { email, first_name: firstName, last_name: lastName },
});
}
// El token del callback (o el de recien crear el customer) todavia no
// sirve como sesion final -- hay que refrescarlo, tanto para usuarios
// nuevos como para los que ya existian (confirmado contra la doc: los
// dos casos convergen en el mismo paso de refresh).
const refreshed = await medusaAuthFetch("/auth/token/refresh", { method: "POST", token: initialToken });
return getCurrentCustomer(refreshed.token as string);
}
export async function registerCustomer(
email: string,
password: string,
firstName: string,
lastName: string,
): Promise<CustomerSummary> {
let data: Record<string, unknown>;
try {
data = await medusaAuthFetch("/auth/customer/emailpass/register", {
method: "POST",
body: { email, password },
});
} catch (error) {
if ((error as Error).message === "Identity with email already exists") {
throw new Error("Ese correo ya está registrado — inicia sesión en vez de crear una cuenta nueva.");
}
throw error;
}
await medusaAuthFetch("/store/customers", {
method: "POST",
token: data.token as string,
body: { email, first_name: firstName, last_name: lastName },
});
// El token de registro crea el customer pero no queda como sesion real
// -- se loguea de nuevo para obtener el token de sesion (confirmado en
// vivo, no documentado explicitamente).
return loginCustomer(email, password);
}
@@ -0,0 +1,97 @@
// ADR 0009: pago real via ePayco (PSE) -- tercer proveedor evaluado
// (PayU LatAm descartado porque Rapyd compró ese negocio en 2023 y el
// registro real quedó con credenciales incompatibles; Rapyd descartado
// por costo). ePayco: 100% de Davivienda desde 2024, comisión PSE más
// baja para el volumen real del usuario, tarifa preferencial con cuenta
// Davivienda. Módulo custom del backend
// (workloads/commerce-backend/src/modules/epayco-pse). Archivo separado
// de medusa-cart.ts por el mismo motivo que ese archivo esta separado de
// medusa.ts -- reutiliza su mismo fetch helper (medusaCartFetch), no
// duplica la logica de headers/no-store.
import { medusaCartFetch } from "./medusa-cart";
export interface PseBank {
pseCode: string;
description: string;
}
// "0" persona natural, "1" persona juridica -- valores reales que espera
// ePayco (campo type_person), no un codigo propio nuestro.
export type PseUserType = "0" | "1";
export type PseDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
export interface PsePaymentInput {
bankCode: string;
userType: PseUserType;
docType: PseDocType;
docNumber: string;
firstName: string;
lastName: string;
email: string;
phone: string;
city: string;
}
// "pp_{identifier}_{id}" -- convencion de Medusa, confirmada contra el
// codigo fuente de @medusajs/payment (identifier "epayco" + id "pse" en
// medusa-config.ts).
const PSE_PROVIDER_ID = "pp_epayco_pse";
export async function getPseBanks(): Promise<PseBank[]> {
const data = await medusaCartFetch("/store/payments/pse-banks");
const banks = (data.banks as Array<{ pseCode?: string; description?: string }> | undefined) ?? [];
return banks
.filter((bank): bank is { pseCode: string; description: string } => Boolean(bank.pseCode && bank.description))
.map((bank) => ({ pseCode: bank.pseCode, description: bank.description }));
}
export async function createPaymentCollection(cartId: string): Promise<string> {
const data = await medusaCartFetch("/store/payment-collections", {
method: "POST",
body: { cart_id: cartId },
});
const collection = data.payment_collection as { id: string };
return collection.id;
}
export async function createPsePaymentSession(paymentCollectionId: string, input: PsePaymentInput): Promise<string> {
const data = await medusaCartFetch(`/store/payment-collections/${paymentCollectionId}/payment-sessions`, {
method: "POST",
body: {
provider_id: PSE_PROVIDER_ID,
data: {
bank_code: input.bankCode,
user_type: input.userType,
doc_type: input.docType,
doc_number: input.docNumber,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
city: input.city,
},
},
});
const collection = data.payment_collection as {
payment_sessions?: Array<{ provider_id: string; data?: { bank_url?: string } }>;
};
const session = collection.payment_sessions?.find((paymentSession) => paymentSession.provider_id === PSE_PROVIDER_ID);
const bankUrl = session?.data?.bank_url;
if (!bankUrl) {
throw new Error("ePayco no devolvió la URL del banco para completar el pago.");
}
return bankUrl;
}
// El carrito se completa (orden creada en estado "awaiting") ANTES de
// redirigir al banco -- authorizePayment del modulo PSE devuelve
// "pending_authorization" a proposito, que es justo el mecanismo de
// Medusa para esto (ver AbstractPaymentProvider). La confirmacion real
// llega despues via el webhook nativo de Medusa, no por esta llamada.
export async function completeCart(cartId: string): Promise<void> {
const data = await medusaCartFetch(`/store/carts/${cartId}/complete`, { method: "POST" });
if (data.type === "cart") {
const error = (data as { error?: { message?: string } }).error;
throw new Error(error?.message || "No se pudo completar el pedido.");
}
}
@@ -111,14 +111,26 @@ async function medusaFetch<T>(
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
// Sin next.revalidate/tags a proposito (reportado por el usuario
// 2026-09-20: imagenes editadas a mano en Medusa no se reflejaban en
// el storefront, pero por CSV si). Causa real: frontend-deploy corre 2
// replicas sin cacheHandler compartido -- cada pod tenia su propio
// cache en memoria de este fetch (next.revalidate:60), y el POST de
// /api/revalidate (revalidateTag) del subscriber de Medusa solo le
// llegaba a UNO de los dos pods via el Service, dejando al otro
// sirviendo la respuesta vieja hasta que el TTL expirara solo. Una
// edicion puntual + revisar al toque tenia ~50% de chance de pegarle
// al pod stale; una importacion CSV (minutos de duracion) le daba
// tiempo de sobra al TTL para expirar en ambos pods, disimulando el
// mismo bug. Las paginas que consumen esto ya son force-dynamic /
// dinamicas por diseño (ver comentario en app/page.tsx y
// app/product/[slug]/page.tsx) -- este next.revalidate competia contra
// esa intencion en vez de cumplirla. Sin este bloque, Next 15 no
// cachea el fetch por default dentro de una ruta dinamica.
const response = await fetch(url, {
headers: publishableKey
? { "x-publishable-api-key": publishableKey }
: undefined,
next: {
revalidate: 60,
tags: ["medusa-products"],
},
});
if (!response.ok) {
+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",
+88 -31
View File
@@ -2,60 +2,117 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { Product } from "@/lib/headless/types";
import type { CartLine } from "@/lib/headless/providers/medusa-cart";
export interface CartItem {
product: Product;
quantity: number;
variantId?: string;
}
// ADR 0009: carrito real conectado a un cart_id de Medusa (Store API,
// via /api/cart/*) en vez de estado 100% local -- precios y existencia
// de linea son los que confirma el servidor, no un calculo optimista
// en el cliente. Primer corte a proposito: sin UI de direccion/envio
// todavia, el checkout sigue cerrando por WhatsApp (CartDrawer).
export type CartItem = CartLine;
interface CartState {
cartId: string | null;
items: CartItem[];
isOpen: boolean;
hasHydrated: boolean;
openCart: () => void;
closeCart: () => void;
setHasHydrated: (value: boolean) => void;
addItem: (product: Product, variantId?: string) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
addItem: (product: { id: string }, variantId?: string) => Promise<void>;
removeItem: (productId: string) => Promise<void>;
updateQuantity: (productId: string, quantity: number) => Promise<void>;
clearCart: () => Promise<void>;
syncCart: () => Promise<void>;
}
async function postJson(url: string, body: unknown) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
(set, get) => ({
cartId: null,
items: [],
isOpen: false,
hasHydrated: false,
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
setHasHydrated: (value) => set({ hasHydrated: value }),
addItem: (product, variantId) => set((state) => {
const existing = state.items.find(
(item) => item.product.id === product.id && item.variantId === variantId,
addItem: async (product, variantId) => {
set({ isOpen: true });
if (!variantId) return;
let cartId = get().cartId;
if (!cartId) {
const cart = await (await fetch("/api/cart", { method: "POST" })).json();
if (!cart.cartId) return;
cartId = cart.cartId;
set({ cartId, items: cart.items ?? [] });
}
const cart = await postJson("/api/cart/items", { cartId, variantId, quantity: 1 });
if (cart.items) set({ items: cart.items });
},
removeItem: async (productId) => {
const { cartId, items } = get();
const line = items.find((item) => item.product.id === productId);
if (!cartId || !line) return;
const response = await fetch(`/api/cart/items/${line.lineItemId}?cartId=${cartId}`, { method: "DELETE" });
const cart = await response.json();
if (cart.items) set({ items: cart.items });
},
updateQuantity: async (productId, quantity) => {
if (quantity <= 0) return get().removeItem(productId);
const { cartId, items } = get();
const line = items.find((item) => item.product.id === productId);
if (!cartId || !line) return;
const cart = await postJson(`/api/cart/items/${line.lineItemId}`, { cartId, quantity });
if (cart.items) set({ items: cart.items });
},
clearCart: async () => {
const { cartId, items } = get();
if (!cartId) return;
await Promise.all(
items.map((item) => fetch(`/api/cart/items/${item.lineItemId}?cartId=${cartId}`, { method: "DELETE" })),
);
const items = existing
? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item)
: [...state.items, { product, quantity: 1, variantId }];
return { items, isOpen: true };
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
})),
updateQuantity: (productId, quantity) => set((state) => ({
items: quantity <= 0
? state.items.filter((item) => item.product.id !== productId)
: state.items.map((item) => item.product.id === productId ? { ...item, quantity } : item),
})),
clearCart: () => set({ items: [] }),
set({ items: [] });
},
// Al recargar la pagina, el cart_id guardado en localStorage puede
// llevar dias -- se refresca contra el servidor en vez de confiar en
// el ultimo snapshot local (precios/stock pudieron cambiar).
syncCart: async () => {
const cartId = get().cartId;
if (!cartId) return;
try {
const response = await fetch(`/api/cart?cartId=${cartId}`);
if (!response.ok) return;
const cart = await response.json();
if (cart.items) set({ items: cart.items });
} catch {
// Sin red: se queda con el ultimo estado local conocido.
}
},
}),
{
name: "ari-shopping-cart-v4",
name: "ari-shopping-cart-v5",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
partialize: (state) => ({ cartId: state.cartId, items: state.items }),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
void state?.syncCart();
},
},
),
);
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Cuenta de cliente real conectada a Medusa (Store API, via
// /api/customer/*), mismo patron que cart-store.ts: token + datos basicos
// persistidos en localStorage, resincronizados contra el servidor al
// rehidratar (el token puede llevar dias, o haber sido revocado).
export interface Customer {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
}
interface CustomerState {
token: string | null;
customer: Customer | null;
hasHydrated: boolean;
loading: boolean;
error: string | null;
setHasHydrated: (value: boolean) => void;
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
login: (email: string, password: string) => Promise<boolean>;
loginWithGoogle: (code: string, state: string) => Promise<boolean>;
logout: () => void;
syncCustomer: () => Promise<void>;
}
async function postJson(url: string, body: unknown) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
function toCustomer(data: Record<string, unknown>): Customer {
return {
id: data.id as string,
email: data.email as string,
firstName: (data.firstName as string | null) ?? null,
lastName: (data.lastName as string | null) ?? null,
};
}
export const useCustomerStore = create<CustomerState>()(
persist(
(set, get) => ({
token: null,
customer: null,
hasHydrated: false,
loading: false,
error: null,
setHasHydrated: (value) => set({ hasHydrated: value }),
register: async (email, password, firstName, lastName) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/register", { email, password, firstName, lastName });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
login: async (email, password) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/login", { email, password });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
loginWithGoogle: async (code, state) => {
set({ loading: true, error: null });
const result = await postJson("/api/customer/google/callback", { code, state });
if (result.error) {
set({ loading: false, error: result.error });
return false;
}
set({ loading: false, token: result.token, customer: toCustomer(result) });
return true;
},
logout: () => set({ token: null, customer: null, error: null }),
// El token guardado en localStorage puede llevar dias o haber sido
// revocado -- se refresca contra el servidor en vez de confiar en el
// ultimo snapshot local.
syncCustomer: async () => {
const token = get().token;
if (!token) return;
try {
const response = await fetch(`/api/customer/me?token=${encodeURIComponent(token)}`);
if (!response.ok) {
set({ token: null, customer: null });
return;
}
const result = await response.json();
if (result.id) set({ customer: toCustomer(result) });
} catch {
// Sin red: se queda con el ultimo estado local conocido.
}
},
}),
{
name: "ari-shopping-customer-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token, customer: state.customer }),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
void state?.syncCustomer();
},
},
),
);