Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SEsGCsCmxkyXi9XctNdrT1
2026-09-06 03:21:36 +00:00
gitea-actions 9e1adf4627 chore(gitops): deploy Medusa v1.0.222 [skip ci] 2026-09-04 14:18:07 +00:00
devops 0ad74b6b70 Merge branch 'chore/product-import-flat-bucket-note' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m5s
2026-09-04 13:54:31 +00:00
devops d1d007fc7d chore(commerce-backend): corregir comentario sobre carpeta de fotos importadas
El S3 file provider de Medusa descarta cualquier subcarpeta del
filename (usa solo path.parse().name + ulid, confirmado leyendo
@medusajs/file-s3/dist/services/s3-file.js) -- no existe una carpeta
import-products/ separada como decia el comentario original. El bucket
(ari-products) es plano, igual para todas las fotos del catalogo.
Confirmado en vivo con una importacion real de 3 productos.
2026-09-04 13:54:23 +00:00
gitea-actions 11ac6d7949 chore(gitops): deploy Medusa v1.0.221 [skip ci] 2026-09-04 05:27:25 +00:00
devops 093ae419d5 Merge branch 'fix/product-import-category-select' into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 16m51s
2026-09-04 04:35:28 +00:00
27 changed files with 1118 additions and 44 deletions

No files matched your search

+8 -1
View File
@@ -26,7 +26,14 @@ ENV CI=true \
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
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 ./
@@ -181,6 +181,33 @@ module.exports = defineConfig({
],
},
},
// 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
@@ -14,6 +14,7 @@ type RowResult = {
row: number;
title: string;
ok: boolean;
action?: "created" | "updated";
error?: string;
};
@@ -91,9 +92,11 @@ const ProductImportPanel = () => {
<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. <code>categoria</code>/<code>coleccion</code> deben
escribirse igual que en la tienda o se dejan vacías. Cada foto se descarga y se guarda en el almacenamiento
propio de la tienda (no queda enlazada al sitio de origen).
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>
@@ -122,7 +125,7 @@ const ProductImportPanel = () => {
<td className="py-2">{result.title}</td>
<td className="py-2">
{result.ok ? (
<Badge color="green">Importado</Badge>
<Badge color="green">{result.action === "updated" ? "Actualizado (ya existía)" : "Importado"}</Badge>
) : (
<span className="text-rose-600">{result.error}</span>
)}
@@ -7,8 +7,11 @@ import type {
IStockLocationService,
} from "@medusajs/framework/types";
import {
createCollectionsWorkflow,
createInventoryItemsWorkflow,
createProductCategoriesWorkflow,
createProductsWorkflow,
updateProductsWorkflow,
uploadFilesWorkflow,
} from "@medusajs/medusa/core-flows";
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
@@ -20,11 +23,37 @@ import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
// 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),
// bajo el prefijo import-products/ para separarlas de ari-products/
// (subidas manuales desde el Admin).
// 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;
@@ -45,9 +74,32 @@ 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";
@@ -112,7 +164,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
const [locations, shippingProfiles, salesChannels, categories, collections] = await Promise.all([
const [locations, shippingProfiles, salesChannels, categories, collections, existingProducts] = await Promise.all([
stockLocationService.listStockLocations({}),
fulfillmentService.listShippingProfiles({}),
salesChannelService.listSalesChannels({}),
@@ -123,6 +175,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
// 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;
@@ -142,6 +195,9 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
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[] = [];
@@ -164,7 +220,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
input: {
files: downloaded.map((file, i) => ({
filename: `import-products/${Date.now()}-${index}-${i}.${file.extension}`,
filename: `${Date.now()}-${index}-${i}.${file.extension}`,
mimeType: file.mimeType,
content: file.content,
access: "public" as const,
@@ -172,6 +228,23 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
},
});
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: {
@@ -188,14 +261,40 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
const inventoryItemId = inventoryItems[0]?.id;
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryId = row.categoria ? categoryByName.get(row.categoria.toLowerCase().trim()) : undefined;
const collectionId = row.coleccion ? collectionByName.get(row.coleccion.toLowerCase().trim()) : undefined;
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,
@@ -219,7 +318,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
},
});
results.push({ row: rowNumber, title, ok: true });
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 });
}
@@ -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 });
}
}
+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;
}
@@ -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ó.
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.207
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 { 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,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 });
}
}
@@ -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.220
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.220
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
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
@@ -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" />
@@ -2,7 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, MapPin, MessageCircle, Truck } from "lucide-react";
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";
@@ -13,11 +13,12 @@ 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, siguiente corte tras el carrito real: direccion + seleccion de
// envio contra el Store API real (mismos endpoints ya verificados en vivo
// para las shipping options). El pago (PayU custom) todavia no existe --
// el ultimo paso sigue siendo WhatsApp, ahora con el pedido completo
// (direccion, envio, total real) en vez de solo la lista de productos.
// 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;
@@ -25,7 +26,12 @@ interface ShippingOption {
amount: number;
}
type Step = "address" | "shipping" | "review";
interface PseBank {
pseCode: string;
description: string;
}
type Step = "address" | "shipping" | "payment";
async function postJson(url: string, body: unknown) {
const response = await fetch(url, {
@@ -60,6 +66,11 @@ export function CheckoutClient() {
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;
@@ -103,7 +114,12 @@ export function CheckoutClient() {
if (cart.error) throw new Error(cart.error);
setShippingTotal(cart.shippingTotal ?? 0);
setTotal(cart.total ?? itemsSubtotal);
setStep("review");
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 {
@@ -111,6 +127,38 @@ export function CheckoutClient() {
}
}
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}`;
@@ -192,17 +240,55 @@ export function CheckoutClient() {
</div>
)}
{step === "review" && (
<div className="mt-8 space-y-4">
{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="text-sm text-slate-500">El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.</p>
<Button variant="primary" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
</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>
@@ -219,8 +305,8 @@ export function CheckoutClient() {
</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 === "review" && <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 === "review" ? total : 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>
);
}
@@ -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}>
@@ -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>
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.219
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.230
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -119,7 +119,9 @@ function mapCart(cart: MedusaCart): CartSummary {
};
}
async function medusaCartFetch(
// 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>> {
@@ -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.");
}
}