Compare commits

..
Author SHA1 Message Date
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 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
58 changed files with 4507 additions and 98 deletions

No files matched your search

+11 -1
View File
@@ -23,7 +23,17 @@ 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 \
PAYU_API_LOGIN=build-only-payu-login \
PAYU_API_KEY=build-only-payu-key \
PAYU_MERCHANT_ID=999999 \
PAYU_ACCOUNT_ID=999999 \
PAYU_ENV=sandbox \
PAYU_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
PAYU_NOTIFY_URL=http://localhost:9000/hooks/payment/payu_pse
COPY package.json package-lock.json .npmrc ./
@@ -157,6 +157,57 @@ 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: PayU LatAm elegido como proveedor de pago -- no existe
// ningun plugin real de Medusa para su API (los paquetes que
// aparecen buscando "PayU" son para PayU Polonia/CEE, API distinta
// por completo), asi que es un modulo custom propio. Alcance inicial:
// solo PSE (transferencia bancaria), no Nequi/Daviplata/tarjetas.
// provider_id resultante: "pp_payu_pse" (identifier "payu" + id
// "pse", convencion de Medusa).
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/payu-pse",
id: "pse",
options: {
apiLogin: required("PAYU_API_LOGIN"),
apiKey: required("PAYU_API_KEY"),
merchantId: required("PAYU_MERCHANT_ID"),
accountId: required("PAYU_ACCOUNT_ID"),
env: process.env.PAYU_ENV || "sandbox",
responseUrl: required("PAYU_RESPONSE_URL"),
notifyUrl: required("PAYU_NOTIFY_URL"),
},
},
],
},
},
],
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
@@ -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,328 @@
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 exacto (case-insensitive/trim), igual que categoria y
// coleccion; en un match, las fotos nuevas se agregan a las que ya
// tenia (no se reemplazan) y la descripcion se actualiza si la fila
// trae una. No toca precio/cantidad/categoria/coleccion del producto
// existente -- eso se edita a mano en el admin si hace falta.
//
// 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.
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) => [c.name.toLowerCase().trim(), c.id]),
);
const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [c.title.toLowerCase().trim(), c.id]),
);
const productByTitle = new Map(
existingProducts.filter((p) => p.title).map((p) => [p.title.toLowerCase().trim(), 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(title.toLowerCase());
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(categoryName.toLowerCase()) : 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(categoryName.toLowerCase(), categoryId);
}
const collectionName = row.coleccion?.trim();
let collectionId = collectionName ? collectionByName.get(collectionName.toLowerCase()) : 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(collectionName.toLowerCase(), 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,37 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { fetchPseBanks, type PayuBank, type PayuEnv } from "../../../../lib/payu";
// 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 PayU
// con las mismas credenciales de medusa-config.ts. Cache en memoria (sin
// Redis/cron) porque PayU solo pide refrescar "una vez al dia" -- mismo
// criterio de simplicidad que ya usa la seccion de Meilisearch en
// medusa-config.ts.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
let cache: { banks: PayuBank[]; 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 banks = await fetchPseBanks({
apiLogin: process.env.PAYU_API_LOGIN ?? "",
apiKey: process.env.PAYU_API_KEY ?? "",
merchantId: process.env.PAYU_MERCHANT_ID ?? "",
accountId: process.env.PAYU_ACCOUNT_ID ?? "",
env: (process.env.PAYU_ENV as PayuEnv) || "sandbox",
});
cache = { banks, fetchedAt: Date.now() };
res.json({ banks });
} catch (error) {
// Degrada a lista vacia en vez de tirar 500 -- el checkout debe poder
// mostrar un mensaje de "no disponible" en vez de romper la pagina
// entera, mismo criterio que /store/search cuando Meilisearch no
// responde.
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;
});
}
+111
View File
@@ -0,0 +1,111 @@
import { createHash } from "node:crypto";
// Contrato real de la API de PayU LatAm (developers.payulatam.com,
// verificado en vivo 2026-09-06/07 -- NO es la misma API que los plugins
// de Medusa que aparecen buscando "PayU": esos son para PayU Polonia/CEE
// (clientId/posId/secondKey), API distinta por completo. Esta es la de
// apiLogin/apiKey/merchantId/accountId, la que cubre Colombia.
export type PayuEnv = "sandbox" | "production";
export interface PayuCredentials {
apiLogin: string;
apiKey: string;
merchantId: string;
accountId: string;
env: PayuEnv;
}
const BASE_URL: Record<PayuEnv, string> = {
sandbox: "https://sandbox.api.payulatam.com/payments-api/4.0/service.cgi",
production: "https://api.payulatam.com/payments-api/4.0/service.cgi",
};
export async function payuRequest(
credentials: PayuCredentials,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const response = await fetch(BASE_URL[credentials.env], {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
test: credentials.env === "sandbox",
language: "es",
...body,
}),
});
const text = await response.text();
let json: Record<string, unknown>;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Respuesta no-JSON de PayU (HTTP ${response.status}): ${text.slice(0, 300)}`);
}
if (!response.ok) {
throw new Error(`PayU respondió HTTP ${response.status}: ${JSON.stringify(json).slice(0, 300)}`);
}
return json;
}
export interface PayuBank {
id: string;
description: string;
pseCode: string;
}
export async function fetchPseBanks(credentials: PayuCredentials): Promise<PayuBank[]> {
const json = await payuRequest(credentials, {
command: "GET_BANKS_LIST",
bankListInformation: { paymentMethod: "PSE", paymentCountry: "CO" },
});
const banks = (json.banks ?? []) as Array<{
id?: unknown;
description?: unknown;
pseCode?: unknown;
}>;
return banks
.filter((bank) => typeof bank.pseCode === "string" && typeof bank.description === "string")
.map((bank) => ({
id: String(bank.id ?? bank.pseCode),
description: String(bank.description),
pseCode: String(bank.pseCode),
}));
}
// Regla documentada de PayU para el campo de monto de la firma: si el
// segundo decimal es cero, un solo decimal (150.00 -> "150.0",
// 150.20 -> "150.2"); si no, dos decimales (150.25 -> "150.25").
export function formatPayuAmount(amount: number): string {
const twoDecimals = amount.toFixed(2);
return twoDecimals.endsWith("0") ? amount.toFixed(1) : twoDecimals;
}
// Firma de la orden (SUBMIT_TRANSACTION) -- distinta de la firma del
// webhook de abajo, confirmado contra el SDK PHP oficial de PayU.
export function buildOrderSignature(params: {
apiKey: string;
merchantId: string;
referenceCode: string;
amount: string;
currency: string;
}): string {
const raw = `${params.apiKey}~${params.merchantId}~${params.referenceCode}~${params.amount}~${params.currency}`;
return createHash("md5").update(raw).digest("hex");
}
// Firma del webhook de confirmación (notifyUrl) -- lo único que impide
// que cualquiera falsifique una confirmación de pago. Falla cerrado:
// cualquier mismatch devuelve false, nunca se traga la excepción.
export function verifyWebhookSignature(params: {
apiKey: string;
merchantId: string;
referenceSale: string;
value: string;
currency: string;
statePol: string;
sign: string;
}): boolean {
const raw = `${params.apiKey}~${params.merchantId}~${params.referenceSale}~${params.value}~${params.currency}~${params.statePol}`;
const expected = createHash("md5").update(raw).digest("hex");
return expected.toLowerCase() === params.sign.toLowerCase();
}
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import PayuPseProviderService from "./service";
export default ModuleProvider(Modules.PAYMENT, {
services: [PayuPseProviderService],
});
@@ -0,0 +1,260 @@
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 { buildOrderSignature, formatPayuAmount, payuRequest, verifyWebhookSignature } from "../../lib/payu";
import type { PayuPseInputData, PayuPseOptions, PayuPseSessionData } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// PSE es AUTHORIZATION_AND_CAPTURE en un solo paso del lado de PayU, y
// asincrono: no hay confirmacion real hasta que llega el webhook (ver
// getWebhookActionAndData). authorizePayment/capturePayment por eso son
// casi no-ops -- el estado real lo trae siempre el webhook, nunca una
// llamada sincrona nuestra a PayU.
export default class PayuPseProviderService extends AbstractPaymentProvider<PayuPseOptions> {
static identifier = "payu";
protected logger_: Logger;
protected options_: PayuPseOptions;
constructor(container: InjectedDependencies, options: PayuPseOptions) {
super(container, options);
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["apiLogin", "apiKey", "merchantId", "accountId", "env", "responseUrl", "notifyUrl"]) {
if (!options[key]) {
throw new Error(`payu-pse: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const data = input.data as unknown as PayuPseInputData & { session_id: string };
const sessionId = data.session_id;
const amount = new BigNumber(input.amount).numeric;
const currency = input.currency_code.toUpperCase();
const formattedAmount = formatPayuAmount(amount);
const signature = buildOrderSignature({
apiKey: this.options_.apiKey,
merchantId: this.options_.merchantId,
referenceCode: sessionId,
amount: formattedAmount,
currency,
});
// Shape del request confirmado contra la documentacion real de PayU
// LatAm para command/paymentMethod/extraParameters -- la anidacion
// exacta de buyer/payer (nombres de campo dentro de esos objetos) no
// quedo verificada campo por campo contra una respuesta real de
// sandbox en esta sesion. Primera prueba end-to-end contra el
// sandbox real de PayU debe confirmar esto (ver plan de
// implementacion, seccion Verificacion).
const json = await payuRequest(
{ apiLogin: this.options_.apiLogin, apiKey: this.options_.apiKey, merchantId: this.options_.merchantId, accountId: this.options_.accountId, env: this.options_.env },
{
command: "SUBMIT_TRANSACTION",
merchant: {
apiLogin: this.options_.apiLogin,
apiKey: this.options_.apiKey,
},
transaction: {
order: {
accountId: this.options_.accountId,
referenceCode: sessionId,
description: `Orden ARI Shopping ${sessionId}`,
language: "es",
signature,
notifyUrl: this.options_.notifyUrl,
additionalValues: {
TX_VALUE: { value: amount, currency },
},
buyer: {
fullName: data.full_name,
emailAddress: data.email,
contactPhone: data.phone,
dniNumber: data.doc_number,
shippingAddress: {
street1: data.address,
city: data.city,
state: data.state,
country: "CO",
},
},
},
payer: {
fullName: data.full_name,
emailAddress: data.email,
contactPhone: data.phone,
dniNumber: data.doc_number,
billingAddress: {
street1: data.address,
city: data.city,
state: data.state,
country: "CO",
},
},
type: "AUTHORIZATION_AND_CAPTURE",
paymentMethod: "PSE",
paymentCountry: "CO",
extraParameters: {
RESPONSE_URL: this.options_.responseUrl,
FINANCIAL_INSTITUTION_CODE: data.bank_code,
USER_TYPE: data.user_type,
PSE_REFERENCE2: data.doc_type,
PSE_REFERENCE3: data.doc_number,
},
},
},
);
const transactionResponse = (json.transactionResponse ?? {}) as Record<string, unknown>;
const extraParameters = (transactionResponse.extraParameters ?? {}) as Record<string, unknown>;
const bankUrl = extraParameters.BANK_URL;
if (typeof bankUrl !== "string" || !bankUrl) {
throw new Error(
`PayU no devolvió BANK_URL para la sesión ${sessionId}: ${JSON.stringify(transactionResponse).slice(0, 300)}`,
);
}
const sessionData: PayuPseSessionData = {
...data,
session_id: sessionId,
payu_order_id: String(transactionResponse.orderId ?? ""),
payu_transaction_id: String(transactionResponse.transactionId ?? ""),
reference_code: sessionId,
bank_url: bankUrl,
state: String(transactionResponse.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, PayU 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", PayU ya liquido el
// dinero de su lado (un solo paso AUTHORIZATION_AND_CAPTURE) -- solo
// persiste el data actualizado.
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: input.data };
}
// PSE no tiene concepto de cancelar una transaccion ya enviada al banco.
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
this.logger_.warn("payu-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 };
}
// No verificado contra el endpoint real de PayU en esta sesión -- con
// dinero real de por medio, mejor tirar un error explícito que fingir
// un reembolso que nunca llamó a PayU.
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
throw new Error(
"payu-pse: refundPayment no está implementado todavía -- el endpoint de reembolso de PayU LatAm no quedó verificado en vivo. Procesar el reembolso manualmente desde el panel de PayU por ahora.",
);
}
// Idem refundPayment: no verificado en vivo, mejor fallar explícito.
async retrievePayment(_input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
throw new Error("payu-pse: retrievePayment no está implementado todavía -- usar el panel de PayU para consultar una transacción.");
}
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// PayU 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 -- si esto se dispara, es una señal de que algo
// cambió el carrito después de crear la sesión.
return { data: input.data };
}
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
const data = input.data as unknown as PayuPseSessionData | undefined;
const state = data?.state;
if (state === "APPROVED") return { status: "captured" };
if (state === "DECLINED" || state === "EXPIRED" || state === "ERROR") return { status: "canceled" };
return { status: "pending" };
}
// 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, es lo unico que impide que cualquiera falsifique
// un pago aprobado.
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
const body = payload.data as Record<string, unknown>;
const merchantId = String(body.merchant_id ?? "");
const referenceSale = String(body.reference_sale ?? "");
const value = String(body.value ?? "");
const currency = String(body.currency ?? "");
const statePol = String(body.state_pol ?? "");
const sign = String(body.sign ?? "");
const validSignature = verifyWebhookSignature({
apiKey: this.options_.apiKey,
merchantId,
referenceSale,
value,
currency,
statePol,
sign,
});
if (!validSignature) {
this.logger_.warn(`payu-pse: webhook con firma inválida para reference_sale=${referenceSale}, ignorado`);
return { action: "not_supported" };
}
// Solo state_pol "4" (aprobado) es exito -- la tabla completa de
// codigos no quedo 100% verificada, asi que cualquier otro valor se
// trata como fallido en vez de asumir que es un exito distinto. Un
// "failed" de mas es reintentable; un "captured" de mas (dinero que
// nunca se aprobo) no lo es.
const action = statePol === "4" ? "captured" : "failed";
return {
action,
data: {
session_id: referenceSale,
amount: new BigNumber(value || 0),
},
};
}
}
@@ -0,0 +1,53 @@
import type { PayuEnv } from "../../lib/payu";
export interface PayuPseOptions {
apiLogin: string;
apiKey: string;
merchantId: string;
accountId: string;
env: PayuEnv;
// A donde PayU redirige al comprador de vuelta tras pagar en el banco
// (pagina propia del storefront, no confirma nada por si sola -- ver
// src/modules/payu-pse/service.ts).
responseUrl: string;
// URL publica de notifyUrl -- la rellena Medusa (ruta nativa
// /hooks/payment/:provider, no la escribimos nosotros), ver
// medusa-config.ts.
notifyUrl: string;
}
export type PayuDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
export type PayuUserType = "N" | "J";
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
// (recolectado en el step "address" del checkout, mas el banco elegido en
// el nuevo step "payment"). No viene de `context.customer` porque el
// checkout es de invitado -- ver ADR 0009.
export interface PayuPseInputData {
bank_code: string;
user_type: PayuUserType;
doc_type: PayuDocType;
doc_number: string;
full_name: string;
email: string;
phone: string;
address: string;
city: string;
// Nombre del departamento (PayU pide "state" como texto libre, no un
// codigo DIVIPOLA como el resto del checkout).
state: string;
}
// Lo que initiatePayment devuelve y queda guardado en `data` de la
// PaymentSession -- publico para el storefront (ver nota en
// AbstractPaymentProvider.initiatePayment), nunca poner credenciales acá.
export interface PayuPseSessionData extends PayuPseInputData {
session_id: string;
payu_order_id: string;
payu_transaction_id: string;
reference_code: 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 },
]),
);
},
);
@@ -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,47 @@ 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>`) — es solo recuperar el propio entorno de trabajo del
contenedor, no tocar infraestructura externa, así que es seguro
hacerlo sin intervención humana.
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).
## 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`) |
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` |
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) |
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) |
## 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ó.
@@ -126,28 +126,29 @@ Verificado contra fuentes reales el mismo día (no memoria):
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, vía el plugin comunitario `@tax1driver/medusa-payu`
(compatibilidad declarada con Medusa 2.4.0+, cubre nuestra versión
2.18.0). Wompi queda descartado por el requisito de registro
Bancolombia/Nequi, no por limitación técnica del plugin en sí.
**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 (API keys, merchant ID, webhook
config) — nunca generadas ni asumidas por mí.
- Verificar el código fuente real de `@tax1driver/medusa-payu` en el
tag exacto a instalar antes de confiar en su documentación, misma
disciplina que ya destapó desviaciones reales en el plugin de
Meilisearch (ADR 0006).
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** (`@tax1driver/medusa-payu`) — **elegido** | Compatibilidad declarada explícitamente con Medusa 2.4.0+ (cubre 2.18.0); 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 | Plugin comunitario, no de primera clase; requiere la misma verificación de código fuente que ya se le hizo al plugin de Meilisearch antes de confiar en él |
| **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 |
@@ -156,17 +157,14 @@ Bancolombia/Nequi, no por limitación técnica del plugin en sí.
- 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.
- Al ser `@tax1driver/medusa-payu` un plugin comunitario (no de
primera clase como Stripe), exige la misma disciplina de
verificación que ya destapó desviaciones reales en el plugin de
Meilisearch (ADR 0006, nota de implementación): leer el código
fuente en el tag exacto que se va a instalar, no el README genérico
ni asumir que "compatibilidad declarada" significa "verificado en
este proyecto".
- Los webhooks de pago (registrados por el plugin en
`/hooks/payment/payu`) 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`.
- 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,
@@ -174,3 +172,56 @@ Bancolombia/Nequi, no por limitación técnica del plugin en sí.
- 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.
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.206
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.224
# 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,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.fullName || !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>
);
}
@@ -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>
);
}
@@ -24,3 +24,11 @@ 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
# PayU LatAm (ADR 0009) -- solo URLs/config no sensible, credenciales
# reales (apiLogin/apiKey/merchantId/accountId) van en
# secrets.template.yaml. PAYU_ENV=sandbox hasta que haya credenciales
# de produccion reales.
PAYU_ENV: sandbox
PAYU_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
PAYU_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/payu_pse
@@ -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.225
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.225
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -35,6 +35,15 @@ 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
# PayU LatAm (ADR 0009) -- credenciales del comercio. Mientras no exista
# cuenta real, usar las de sandbox genéricas y públicas de PayU
# (developers.payulatam.com/latam/en/docs/getting-started/test-your-solution.html),
# nunca hardcodeadas acá.
PAYU_API_LOGIN: CAMBIAR
PAYU_API_KEY: CAMBIAR
PAYU_MERCHANT_ID: CAMBIAR
PAYU_ACCOUNT_ID: 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,314 @@
"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 PayU LatAm/PSE (modulo custom del backend, ver
// workloads/commerce-backend/src/modules/payu-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<"N" | "J">("N");
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,
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,
fullName: `${form.firstName} ${form.lastName}`.trim(),
email: form.email,
phone: form.phone,
address: form.address1,
city: selectedCiudad.name,
state: selectedDepartamento.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 PayU 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 === "N"} onChange={() => setUserType("N")} />Persona natural</label>
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "J"} onChange={() => setUserType("J")} />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";
// PayU 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 PayU al
// backend (getWebhookActionAndData en el modulo payu-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. PayU 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>
);
}
@@ -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):
@@ -62,7 +66,15 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</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>
{/* 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>}
@@ -108,19 +120,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 +199,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 +234,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 +256,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}>
@@ -7,5 +7,19 @@ 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>;
const outOfStock = product.variants[0]?.available === false;
return (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden">
<Button
variant="primary"
size="lg"
className="w-full"
disabled={outOfStock}
onClick={() => addItem(product, product.variants[0]?.id)}
>
<ShoppingBag />
{outOfStock ? "Agotado" : "Añadir al carrito"}
</Button>
</div>
);
}
@@ -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,36 @@
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 HoverZoomOrigin { x: number; y: number }
// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce)
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma
// continua en touch, asi que en mobile simplemente no aparece el efecto,
// sin romper nada). Independiente del zoom interactivo del modal
// (click -> Dialog con scale/drag), que sigue igual.
const HOVER_ZOOM_SCALE = 2.2;
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
const [selected, setSelected] = useState(images[0] ?? "");
const [open, setOpen] = useState(false);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
const drag = useRef<DragState | null>(null);
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setHoverZoom({
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
});
};
const applyScale = (next: number) => {
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
setScale(value);
@@ -44,8 +61,24 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
</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]" />
<button
type="button"
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
onMouseMove={trackHoverZoom}
onMouseLeave={() => setHoverZoom(null)}
>
<Image
src={selected}
alt={name}
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover transition-transform duration-150 ease-out"
style={{
transform: `scale(${hoverZoom ? HOVER_ZOOM_SCALE : 1})`,
transformOrigin: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center",
}}
/>
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
</button>
</DialogTrigger>
@@ -1,6 +1,6 @@
"use client";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
@@ -11,12 +11,68 @@ 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>
{/* 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"
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>
</div>
);
}
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.204
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.226
env:
- name: HEADLESS_PROVIDER
value: medusa
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,244 @@
// 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;
}
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,
},
},
});
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,92 @@
// ADR 0009: pago real via PayU LatAm (PSE), modulo custom del backend
// (workloads/commerce-backend/src/modules/payu-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;
}
export type PseUserType = "N" | "J";
export type PseDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
export interface PsePaymentInput {
bankCode: string;
userType: PseUserType;
docType: PseDocType;
docNumber: string;
fullName: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
}
// "pp_{identifier}_{id}" -- convencion de Medusa, confirmada contra el
// codigo fuente de @medusajs/payment (identifier "payu" + id "pse" en
// medusa-config.ts).
const PSE_PROVIDER_ID = "pp_payu_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,
full_name: input.fullName,
email: input.email,
phone: input.phone,
address: input.address,
city: input.city,
state: input.state,
},
},
});
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("PayU 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.");
}
}
+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();
},
},
),
);