Compare commits

...
Author SHA1 Message Date
devopsandClaude Sonnet 5 9d231322b8 docs(adr-0010): resultados de pruebas con navegador real + checklist ampliado
Cierra la mayor parte del checklist de pruebas reales que había quedado
pendiente: pago PSE hasta la redirección al banco, envío calculado
visible, banner y buscador mobile -- todo verificado con Chromium real
(Docker + Playwright en la NAS, ver .browser-scratch/README.md). Deja
documentados los 2 bugs reales que esa prueba encontró (ya corregidos
en otro commit) y un checklist ampliado para la próxima sesión.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-09 03:35:51 +00:00
gitea-actions a973b2671e chore(gitops): deploy Medusa v1.0.234 [skip ci] 2026-09-08 21:52:18 +00:00
devops bdd7c06a32 chore: release v1.0.235 [skip ci] 2026-09-08 21:25:08 +00:00
devops 0c356d6fbe Merge feat/envioclick-fulfillment into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 12m5s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 27m11s
2026-09-08 21:11:54 +00:00
devopsandClaude Sonnet 5 c507da8abc feat(fulfillment): integrar EnvíoClick para cálculo real de envío
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-08 21:11:43 +00:00
gitea-actions e42d588514 chore(gitops): deploy Medusa v1.0.233 [skip ci] 2026-09-07 04:47:15 +00:00
devops e2938248f8 chore: release v1.0.232 [skip ci] 2026-09-07 04:23:21 +00:00
devops 4992da394b Merge fix/epayco-package-lock into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m24s
2026-09-07 04:17:03 +00:00
21 changed files with 1109 additions and 83 deletions

No files matched your search

+10 -1
View File
@@ -33,7 +33,16 @@ ENV CI=true \
EPAYCO_P_KEY=build-only-epayco-pkey \
EPAYCO_ENV=sandbox \
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \
ENVIOCLICK_API_KEY=build-only-envioclick-key \
ENVIOCLICK_SANDBOX=true \
ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \
ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \
ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \
ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \
ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \
ENVIOCLICK_ORIGIN_EMAIL=[email protected] \
ENVIOCLICK_ORIGIN_PHONE=3000000000
COPY package.json package-lock.json .npmrc ./
@@ -133,6 +133,45 @@ module.exports = defineConfig({
},
modules: [
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
// directo) -- pero como este bloque propio comparte el mismo tipo de
// modulo, transformModules() lo pisa por completo (indexa por
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
// manual_manual va explicito ACA tambien: si se omitiera, la
// shipping option existente que ya usa ese provider se rompe en
// silencio. envioclick-fulfillment implementa el mismo contrato
// (AbstractFulfillmentProviderService) contra
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
// con una llamada de prueba real que devolvio tarifas reales de
// varias transportadoras.
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "@medusajs/medusa/fulfillment-manual",
id: "manual",
},
{
resolve: "./src/modules/envioclick-fulfillment",
id: "standard",
options: {
apiKey: required("ENVIOCLICK_API_KEY"),
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "11001000",
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
},
},
],
},
},
{
resolve: "@medusajs/medusa/file",
options: {
@@ -12,6 +12,12 @@ import { createEpaycoClient, type EpaycoEnv } from "../../../../lib/epayco";
// contra el sandbox real en esta sesion -- se prueban los nombres de
// campo mas probables (bankCode/bankName y variantes) y se degrada a
// lista vacia si no matchea nada, en vez de romper el checkout entero.
//
// Verificado en vivo despues (navegador real, Playwright): el sandbox
// real de ePayco incluye un primer elemento placeholder
// (bankCode "0", "A continuación seleccione su banco...") mezclado con
// los bancos reales -- ningun banco PSE real tiene codigo "0", asi que
// se filtra explicitamente en vez de mostrarlo como opcion seleccionable.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
let cache: { banks: Array<{ pseCode: string; description: string }>; fetchedAt: number } | null = null;
@@ -42,7 +48,7 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
? { pseCode: String(pseCode), description: String(description ?? pseCode) }
: null;
})
.filter((bank): bank is { pseCode: string; description: string } => bank !== null);
.filter((bank): bank is { pseCode: string; description: string } => bank !== null && bank.pseCode !== "0");
cache = { banks, fetchedAt: Date.now() };
res.json({ banks });
@@ -0,0 +1,179 @@
// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones
// del checkout): cotización y generación de guía real por peso/volumen,
// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en
// vivo contra la cuenta real del usuario (apiDoc real,
// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no
// renderiza sin JS, pero ese archivo generado por apidoc trae la spec
// completa) y una llamada de prueba real que devolvió 200 con tarifas
// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC.
const BASE_URL = "https://api.envioclickpro.com.co/api/v2";
export interface EnvioClickCredentials {
apiKey: string;
}
export interface EnvioClickPackage {
weight: number;
height: number;
length: number;
// "widht" es un typo real de la API (confirmado en el body de
// /shipment de la doc oficial), no un error nuestro -- solo se usa acá,
// en el request de /quotation el campo si se llama "width" (confirmado
// tambien contra la doc real, son dos endpoints con esa inconsistencia).
width: number;
}
export interface EnvioClickRate {
idRate: number;
idProduct: number;
product: string;
idCarrier: number;
carrier: string;
flete: number;
minimumInsurance: number;
extraInsurance: number;
deliveryDays: number;
cod: boolean;
}
export interface EnvioClickAddress {
daneCode: string;
address: string;
}
async function envioclickRequest(
credentials: EnvioClickCredentials,
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Header real confirmado contra la doc oficial y una llamada en
// vivo -- "Authorization" a secas (sin "Bearer"), NO
// "AuthorizationKey" como usaba el plugin de WordPress de
// referencia (ese era para la API v1, desactualizada).
Authorization: credentials.apiKey,
},
body: JSON.stringify(body),
});
const text = await response.text();
let json: Record<string, unknown>;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`);
}
if (json.status !== "OK") {
throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`);
}
return json;
}
// Solo 1 paquete por cotizacion (limite real documentado y confirmado:
// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete).
export async function quoteShipment(
credentials: EnvioClickCredentials,
params: {
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickAddress;
destination: EnvioClickAddress;
},
): Promise<EnvioClickRate[]> {
const json = await envioclickRequest(credentials, "/quotation", {
packages: [params.pkg],
description: params.description,
contentValue: params.contentValue,
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return (data.rates as EnvioClickRate[]) ?? [];
}
export interface EnvioClickPerson {
company: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
suburb: string;
crossStreet: string;
reference: string;
daneCode: string;
}
export interface CreateGuideResult {
guide: string;
url: string;
tracker: string;
idOrder: number;
}
export async function createGuide(
credentials: EnvioClickCredentials,
sandbox: boolean,
params: {
idRate: number;
myShipmentReference: string;
externalOrderId: string;
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickPerson;
destination: EnvioClickPerson;
},
): Promise<CreateGuideResult> {
// /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la
// cotizacion siempre usa tarifas reales de las transportadoras, no
// tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a
// la URL de producción para que puedas generar guías reales").
const path = sandbox ? "/shipment_sandbox" : "/shipment";
const json = await envioclickRequest(credentials, path, {
idRate: params.idRate,
myShipmentReference: params.myShipmentReference,
external_order_id: params.externalOrderId,
requestPickup: false,
pickupDate: new Date().toISOString().slice(0, 10),
insurance: false,
description: params.description,
contentValue: params.contentValue,
packages: [
{
weight: params.pkg.weight,
height: params.pkg.height,
length: params.pkg.length,
widht: params.pkg.width,
},
],
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return {
guide: String(data.guide ?? ""),
url: String(data.url ?? ""),
tracker: String(data.tracker ?? ""),
idOrder: Number(data.idOrder ?? 0),
};
}
// Hasta 50 ordenes por llamada (limite real documentado).
export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise<void> {
await envioclickRequest(credentials, "cancellation/batch/order", { idOrders });
}
// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio,
// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa
// lib/data/colombia.ts del storefront, confirmado contra su propio
// comentario) con el padding real que espera EnvioClick (8 caracteres,
// confirmado contra su propio ejemplo real: "11001000" para Bogota =
// "11001" + "000").
export function toEnvioClickDaneCode(divipolaCityId: number): string {
return `${String(divipolaCityId).padStart(5, "0")}000`;
}
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import EnvioClickFulfillmentService from "./service";
export default ModuleProvider(Modules.FULFILLMENT, {
services: [EnvioClickFulfillmentService],
});
@@ -0,0 +1,275 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils";
import type {
CalculatedShippingOptionPrice,
CalculateShippingOptionPriceContext,
CalculateShippingOptionPriceDTO,
CreateFulfillmentResult,
CreateShippingOptionDTO,
FulfillmentDTO,
FulfillmentItemDTO,
FulfillmentOption,
FulfillmentOrderDTO,
Logger,
ValidateFulfillmentDataContext,
} from "@medusajs/framework/types";
import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick";
import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// Contexto minimo compartido entre calculatePrice/createFulfillment --
// ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado
// como metadata en la direccion por el checkout -- ver ADR 0009 y
// checkout-client.tsx del storefront) y el paquete agregado del pedido.
interface AddressLike {
metadata?: Record<string, unknown> | null;
address_1?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
email?: string | null;
}
function destinationDaneCode(address: AddressLike | null | undefined): string | null {
const cityId = address?.metadata?.cityId;
return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null;
}
// Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por
// cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun
// item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver
// plan de implementacion), se usa el paquete de fallback conservador
// completo en vez de mezclar datos reales con inventados a medias.
function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) {
if (items.length === 0) return { ...DEFAULT_PACKAGE };
let totalWeight = 0;
for (const item of items) {
const weight = item.variant?.weight;
if (!weight) return { ...DEFAULT_PACKAGE };
totalWeight += weight * item.quantity;
}
return { ...DEFAULT_PACKAGE, weight: totalWeight };
}
export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "envioclick";
protected logger_: Logger;
protected options_: EnvioClickOptions;
constructor(container: InjectedDependencies, options: EnvioClickOptions) {
super();
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) {
if (!options[key]) {
throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
// Una sola opcion: siempre toma la tarifa mas barata entre las
// transportadoras que cotizan, mismo criterio que el plugin oficial
// de WooCommerce de referencia ("toma el precio mas economico").
return [{ id: "standard", name: "Envío nacional (calculado)" }];
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
_context: ValidateFulfillmentDataContext,
): Promise<any> {
return data;
}
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
return true;
}
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
return true;
}
// Se invoca en vivo durante el checkout cada vez que se piden/refrescan
// las shipping options del carrito. Medusa advierte explicitamente que
// un error acá bloquea el checkout entero -- por eso NUNCA se deja
// propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA,
// EnvioClick caido) cae al precio fijo que ya se usaba antes de esta
// integracion, para no romper la compra.
async calculatePrice(
optionData: CalculateShippingOptionPriceDTO["optionData"],
_data: CalculateShippingOptionPriceDTO["data"],
context: CalculateShippingOptionPriceContext,
): Promise<CalculatedShippingOptionPrice> {
const fallback: CalculatedShippingOptionPrice = {
calculated_amount: FALLBACK_PRICE_COP,
is_calculated_price_tax_inclusive: false,
};
try {
const address = context.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo");
return fallback;
}
const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) return fallback;
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
return {
calculated_amount: cheapest.flete + cheapest.minimumInsurance,
is_calculated_price_tax_inclusive: false,
};
} catch (error) {
this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`);
return fallback;
}
}
// A diferencia de calculatePrice, acá SÍ se deja propagar el error --
// el idRate de la cotización mostrada en checkout no se persiste en
// ningún lado (CalculatedShippingOptionPrice no tiene un campo `data`
// para eso, confirmado leyendo el tipo real), así que se vuelve a
// cotizar en el momento de generar la guía (más correcto igual: el
// fulfillment puede pasar horas/días después del checkout, un idRate
// viejo podría estar vencido) y se reserva con la tarifa más barata de
// esa cotización fresca.
async createFulfillment(
_data: Record<string, unknown>,
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
order: Partial<FulfillmentOrderDTO> | undefined,
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
): Promise<CreateFulfillmentResult> {
const address = order?.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío.");
}
const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) {
throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía.");
}
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
const guide = await createGuide(
{ apiKey: this.options_.apiKey },
this.options_.sandbox,
{
idRate: cheapest.idRate,
myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`,
externalOrderId: String(order?.id ?? ""),
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: {
company: this.options_.originCompany,
firstName: this.options_.originFirstName,
lastName: this.options_.originLastName,
email: this.options_.originEmail,
phone: this.options_.originPhone,
address: this.options_.originAddress,
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode: this.options_.originDaneCode,
},
destination: {
company: "N/A",
firstName: address?.first_name || "Cliente",
lastName: address?.last_name || "ARI",
email: address?.email || order?.email || "[email protected]",
phone: address?.phone || "0000000000",
address: address?.address_1 || "Dirección de entrega",
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode,
},
},
);
return {
data: {
idOrder: guide.idOrder,
tracker: guide.tracker,
labelUrl: guide.url,
},
labels: [
{
tracking_number: guide.tracker,
tracking_url: guide.url,
label_url: guide.url,
},
],
};
}
async cancelFulfillment(data: Record<string, unknown>): Promise<any> {
const idOrder = data.idOrder;
if (typeof idOrder !== "number") {
this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick");
return { data };
}
await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]);
return { data };
}
async getFulfillmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
// Devoluciones no estan en alcance de esta primera version.
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
}
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
return undefined;
}
}
@@ -0,0 +1,33 @@
export interface EnvioClickOptions {
apiKey: string;
// El unico endpoint con modo de pruebas real es la generacion de guia
// (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas
// reales, no hay nada que alternar ahi.
sandbox: boolean;
// Datos de la bodega de origen -- la stock location real todavia no
// tiene direccion fisica cargada ("Sin dirección física registrada"),
// asi que estos vienen de config hasta que se cargue una real.
originDaneCode: string;
originAddress: string;
originCompany: string;
originFirstName: string;
originLastName: string;
originEmail: string;
originPhone: string;
}
// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones
// reales del pedido (hoy ningun producto los tiene cargados) -- mismo
// valor que ya usaba la shipping option fija de ADR 0009, para no
// empeorar la experiencia mientras no haya datos reales.
export const FALLBACK_PRICE_COP = 15000;
// Paquete por defecto cuando algun item del carrito no tiene
// peso/dimensiones reales -- conservador, no preciso. Ver nota en el
// plan de implementacion: esto es una tarea de datos, no de codigo.
export const DEFAULT_PACKAGE = {
weight: 1,
length: 20,
height: 20,
width: 20,
};
@@ -1,7 +1,10 @@
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
**Estado:** aceptada — proveedor de pago decidido (PayU, §3);
implementación real (credenciales, shipping options, código) pendiente
**Estado:** aceptada — proveedor de pago decidido (PayU, §3); la
implementación real terminó usando ePayco en vez de PayU (PayU LatAm
quedó inviable, Rapyd se evaluó y se descartó sin código) y sumó
EnvíoClick para envío calculado — ver [ADR 0010](./0010-pago-envio-reales-y-storefront.md)
para el pivote completo y el estado final desplegado
**Fecha:** 2026-08-28
## Contexto
@@ -0,0 +1,356 @@
# ADR 0010 — Pago (ePayco PSE) y envío (EnvíoClick) reales; banner y buscador del storefront
**Estado:** aceptada — ambas integraciones desplegadas y verificadas en vivo (sandbox de cada proveedor); banner dinámico y buscador rediseñado desplegados
**Fecha:** 2026-09-09
## Contexto
El ADR 0009 dejó **PayU** decidido como proveedor de pago de negocio y
documentó la necesidad de un módulo custom (`AbstractPaymentProvider`)
porque ningún plugin comunitario de Medusa sirve para la API real de
PayU LatAm. Entre esa decisión y la implementación real pasaron dos
correcciones más, ninguna prevista en el ADR original — este documento
cierra esa cadena y agrega la segunda mitad de "checkout real" que el
0009 dejó pendiente: el envío (EnvíoClick), más dos mejoras de UI del
storefront relacionadas (banner dinámico, buscador) pedidas una vez que
ambas integraciones ya estaban en producción.
## 1. Pago — de PayU a Rapyd a ePayco
### 1.1 PayU LatAm quedó inviable: Rapyd compró ese negocio
Al intentar registrar la cuenta de comercio en `developers.payulatam.com`
(2026-08-29), el registro real redirige a **Rapyd** — confirmado en vivo
contra `board.rapyd.net` con capturas de pantalla del propio usuario, no
solo documentación. Investigación confirmó la causa: **Rapyd adquirió el
negocio de PayU LatAm/África en 2023.** PayU LatAm como entidad separada
con la API documentada en el ADR 0009 (`apiLogin`/`apiKey`/`accountId`)
ya no es la vía de registro real para un comercio nuevo.
Se investigó y planificó por completo la alternativa Rapyd (API REST
moderna, firma HMAC-SHA256, endpoint `/v1/payments` — arquitectura
totalmente distinta a la de PayU LatAm clásico, no una migración menor
de credenciales) pero **nunca se escribió código**: antes de implementar,
el usuario planteó ePayco como alternativa y, al evaluarla, resultó
preferible por costo/encaje — Rapyd quedó descartado en la etapa de plan,
no de código.
### 1.2 Decisión final: ePayco
**Motivo de negocio:** ePayco es 100% propiedad de Davivienda desde 2024
— mejor tarifa PSE dado que Davivienda ya es uno de los bancos
disponibles del comercio (mismo dato de negocio del ADR 0009 §3:
Davivienda, Lulo Bank, Banco de Bogotá).
**Hallazgo técnico no obvio, verificado en vivo, no en documentación:**
ePayco tiene dos productos de integración distintos bajo la misma marca:
- Un producto nuevo tipo "Apify"/Web-Checkout (widget alojado).
- La integración real de PSE que efectivamente se usó: la API
"restpagos" / `secure.payco.co`, **más antigua**, que combina login con
bearer token **más cifrado AES-CBC campo por campo** usando la llave
privada del comercio como clave AES con IV en cero — un protocolo poco
común que no vale la pena reimplementar a mano. Se optó por depender
del paquete oficial `epayco-sdk-node` en vez de escribir el cliente
HTTP propio (a diferencia de EnvíoClick, ver §2).
Nombres de método/campo reales del SDK, corregidos contra su código
fuente instalado (no contra su propia documentación pública, que estaba
desactualizada respecto al paquete):
- Lista de bancos PSE: `getBanks()` (la doc pública sugiere `pseBank()`,
que no existe en el SDK real).
- Confirmación de método: campo `method_confirmation` (la doc pública
sugiere `metodoconfirmacion`).
**Implementación** (`commerce-backend/src/modules/epayco-pse/`,
`src/lib/epayco.ts`): módulo de pago custom
(`AbstractPaymentProvider`), estado `pending_authorization` tras iniciar
el pago (mecanismo documentado de Medusa para métodos asíncronos —
permite que el carrito complete en una orden "esperando confirmación" sin
bloquear sobre la respuesta del banco), webhook nativo de Medusa en
`/hooks/payment/epayco_pse` (sin subscriber custom — el
`payment-webhook` + `process-payment-workflow` incluidos en el core ya
completan la orden automáticamente al recibir la notificación).
**Verificado en vivo (2026-09):**
- Credenciales reales del comercio (`P_CUST_ID_CLIENTE`, `P_KEY`,
`PUBLIC_KEY`, `PRIVATE_KEY`) cargadas en el secret real del cluster.
- Llamada real a `getBanks()` contra la API de ePayco devolvió el listado
real de bancos PSE en modo sandbox.
- El webhook `/hooks/payment/epayco_pse` es alcanzable desde afuera:
`POST` externo contra `https://commerce.cruzcloud.net/hooks/payment/epayco_pse`
responde `200 OK` (la firma inválida se maneja puertas adentro sin
romper la respuesta HTTP) — cierra el riesgo de red que el ADR 0009 §2
había señalado como pendiente de validar (mismo patrón de incidente ya
documentado para otro servicio,
`incidente-cloudflared-504-quic-2026-08.md`).
**Limitaciones conocidas y aceptadas, no bloqueantes:**
- `refundPayment` tira un error explícito de "no implementado" — el
endpoint real de reembolso nunca se verificó en vivo, se prefirió
fallar ruidosamente a fingir éxito.
- El nombre exacto del campo de la URL de redirección del banco en la
respuesta de `bank.create()` se maneja de forma defensiva, probando
varios nombres candidatos con un error explícito y el payload completo
si ninguno matchea — **todavía no se probó con un pago PSE real de
principio a fin** (solo `getBanks()` está confirmado en vivo). Ver §3
para cómo cerrar esto desde el storefront real.
- El comercio está en modo **sandbox** de ePayco (`EPAYCO_ENV: sandbox`
en el ConfigMap) — no se ha movido dinero real todavía.
## 2. Envío — EnvíoClick reemplaza la tarifa fija
El ADR 0009 (nota de implementación) dejó una shipping option con tarifa
fija de $15.000 COP como **placeholder de negocio**, anotando
explícitamente que un cálculo real por peso/volumen era trabajo futuro
— "eso es justamente lo que EnvíoClick resolvería más adelante". Este
ADR cierra ese punto.
### Hallazgo de negocio, no técnico, que condiciona la precisión real
**Ningún producto del catálogo tiene peso ni dimensiones cargados**
(confirmado contra la base real: 18 productos activos, 0 con `weight`,
0 con `length`). Sin este dato, cualquier cotización real de EnvíoClick
no puede ser precisa. Se aceptó como **tarea de datos pendiente, no de
código** — la integración usa un paquete de fallback conservador (1 kg,
20×20×20 cm) mientras no haya datos reales por producto, documentado en
`src/modules/envioclick-fulfillment/types.ts` (`DEFAULT_PACKAGE`).
### API real, corregida contra la fuente primaria, no contra un plugin de referencia desactualizado
Un plugin de WordPress usado como primera referencia resultó ser para la
**API v1** de EnvíoClick, ya obsoleta. La doc real
(`apidoc.envioclickpro.com.co`, generada con apiDoc — se bajó y leyó
directo `api_data.json`/`api_project.json`, ya que la SPA no renderizaba
sin sesión) reveló diferencias reales, confirmadas con una llamada real
exitosa:
| | Plugin de referencia (v1, desactualizado) | API real (v2, confirmada en vivo) |
|---|---|---|
| Base URL | `api.envioclickpro.com` | `https://api.envioclickpro.com.co/api/v2` |
| Header de auth | `AuthorizationKey: <key>` | `Authorization: <key>` (sin prefijo) |
`POST /quotation` devolvió en vivo tarifas reales de PIBOX, COORDINADORA,
ENVIA, RAPPI y TCC para una cotización de prueba real — se toma
`flete + minimumInsurance` (seguro mínimo obligatorio, hay que sumarlo al
flete para el precio real a cobrar) más barato del array `data.rates[]`,
mismo criterio de "más económico" que ya usaba el plugin de referencia.
### Hallazgo crítico de Medusa, evitado antes de romper nada (no un incidente real)
Leyendo directo `@medusajs/utils/dist/common/define-config.js` (no
documentación): `defineConfig()` ya registra por defecto
`Modules.FULFILLMENT` con el provider `manual_manual`, aunque
`medusa-config.ts` no lo mencione explícitamente. `transformModules()`
indexa por `serviceName` y el último bloque que se declara **pisa**, no
suma, al anterior. Agregar un bloque de fulfillment propio sin incluir
también `@medusajs/medusa/fulfillment-manual` adentro habría roto el
fulfillment manual existente en silencio — mismo tipo de riesgo que casi
pasó con `payment` en el ADR 0009, evitado leyendo el código fuente real
antes de escribir el bloque nuevo (ambos providers conviven hoy en el
mismo bloque de `medusa-config.ts`).
**Implementación** (`commerce-backend/src/modules/envioclick-fulfillment/`,
`src/lib/envioclick.ts`): `AbstractFulfillmentProviderService`,
`calculatePrice()` invocado en vivo cada vez que el carrito refresca
shipping options (Medusa advierte que si este método falla, **bloquea el
checkout** — el fallback conservador de arriba existe también por esto,
no solo por falta de datos). `createFulfillment()` genera la guía real vía
`/shipment` (o `/shipment_sandbox` en modo prueba —
**el único endpoint con sandbox real**, la cotización siempre usa
tarifas reales). Sin cambios en el storefront: `GET /store/shipping-options`
nativo de Medusa ya invocaba `calculatePrice()` automáticamente para
cualquier shipping option `price_type: "calculated"`.
**Verificado en vivo (2026-09):**
- Shipping option "Envío nacional estándar" migrada de `manual_manual`/
tarifa fija a `envioclick_standard`/`calculated` vía Admin API (con
usuario admin temporal, eliminado después de usarlo).
- Un `400` inicial (`"Providers (envioclick_standard) are not enabled
for the service location"`) se resolvió vinculando el provider a la
stock location (`POST /admin/stock-locations/:id/fulfillment-providers`)
— Medusa exige esto además del bloque de módulo.
- Carrito de prueba real con dirección → `shipping_total: 8941` COP,
tarifa real de EnvíoClick, no el fijo de $15.000.
- Datos reales de la bodega de origen cargados (dirección `Cra 100
#42-65`, contacto `cristiancruz29@outlook.com` / `3014750823`) tanto en
el ConfigMap (`ENVIOCLICK_ORIGIN_*`) como en el registro real de la
stock location en Medusa (antes: "Sin dirección física registrada").
- El comercio está en modo **sandbox** de EnvíoClick
(`ENVIOCLICK_SANDBOX: "true"`) — las guías que se generan son de
prueba, sin costo; ninguna cotización es de prueba (no existe sandbox
de cotización).
**Limitaciones conocidas y aceptadas, no bloqueantes:**
- `createReturnFulfillment` tira "no implementado" — devoluciones fuera
de alcance.
- Cancelación de guía real (`POST cancellation/batch/order`, hasta 50 por
lote, usando los `idOrder` que devuelve `/shipment`) confirmada como
existente en la API pero **no implementada en el módulo** — se
descubrió durante la investigación, no se integró por no ser parte del
flujo de checkout mínimo.
- Solo EnvíoClick como provider — no hay selección de múltiples
couriers por el comprador (v1 siempre toma el más barato).
- Ningún producto tiene peso/dimensiones reales — ver arriba.
## 3. Storefront — banner dinámico y buscador
Con ambas integraciones ya en producción, se atendieron dos pedidos de
UI del storefront relacionados con la experiencia de compra real:
### 3.1 Banner dinámico (`components/home/hero-carousel.tsx`)
Antes: tres slides con copy e imágenes fijas, hardcodeadas en el
componente, sin relación con el catálogo real. Ahora: `HomePage`
(`app/page.tsx`, que ya hace `getProducts()` del backend real) le pasa al
carrusel los productos marcados como `featured` en el admin (o, si no hay
ninguno, los mismos "tesoros" —nuevos o destacados— que ya usa la sección
de abajo, para no duplicar el criterio de selección); el carrusel arma
cada slide con la imagen, nombre, precio (`priceLabel`) y link reales del
producto, calculando el badge de descuento (`% OFF`) cuando
`compareAtPrice` existe. Si la tienda no tuviera ningún producto activo
(caso borde), cae a un slide estático de respaldo para no romper la
página. Autoplay, dots de paginación y animación quedan igual — solo
cambia el origen de los datos.
### 3.2 Buscador (`components/layout/navbar.tsx`)
El header en mobile competía el buscador con el logo y los tres iconos
de acción en una sola fila de grid (`grid-cols-[auto_1fr_auto]`),
quedando visualmente recortado — reportado por el usuario junto a una
captura de referencia del header mobile de Best Buy (fila de iconos
arriba, buscador a todo el ancho debajo, con micrófono y escaneo QR
inline).
**Decisión:** adoptar el mismo patrón de dos filas en mobile/tablet
(logo + iconos arriba, buscador a todo el ancho debajo) pero **sin** los
íconos de micrófono/QR — no hay búsqueda por voz ni por código de barras
implementada, y un ícono sin función real confunde más de lo que ayuda.
En desktop (`lg:` y arriba) se mantuvo la fila única original del grid
`[auto_1fr_auto]`: ahí ya sobraba espacio horizontal y partirla en dos
solo alargaría el header sin necesidad. Sin cambios en la lógica de
`PredictiveSearch` (autocompletado, debounce, navegación) — solo se
reubicó su contenedor según el breakpoint.
## Opciones consideradas (pago)
| Opción | A favor | En contra |
|---|---|---|
| PayU LatAm (ADR 0009) | Ya decidido, credenciales bien documentadas | Registro real redirige a Rapyd — la entidad "PayU LatAm" independiente ya no existe para un comercio nuevo |
| Rapyd | Dueño real del negocio ex-PayU LatAm, API moderna y bien documentada | API completamente distinta (HMAC, REST moderno) — no es una migración de credenciales, es una integración nueva desde cero; descartada en fase de plan por costo/encaje frente a ePayco |
| **ePayco** — elegido | 100% Davivienda desde 2024, mejor tarifa PSE con el banco ya disponible del comercio; SDK oficial cubre el protocolo inusual (login + cifrado AES por campo) | Dos productos bajo la misma marca (Apify vs. la API real usada) fáciles de confundir; doc pública desactualizada respecto al SDK real; reembolsos no implementados |
## Consecuencias
- Checkout real de punta a punta (pago + envío calculado) queda
desplegado y verificado con navegador real hasta la redirección al
banco de PSE (ver nota de implementación 2026-09-10 abajo), pero
**ambos proveedores siguen en modo sandbox** — no se ha completado ni
un pago real ni un envío real todavía, y el login bancario de PSE
tampoco se completó (falta credencial de prueba). Ver checklist
ampliado abajo.
- La precisión de la tarifa de envío depende de cargar peso/dimensiones
reales por producto — deuda de datos explícita, no de código; candidato
natural a extender el importador CSV existente con columnas nuevas.
- Reembolsos (ePayco) y devoluciones/cancelación de guía (EnvíoClick)
quedan fuera de alcance — fallan explícitamente en vez de fingir éxito.
- Revertir el pago implica deshabilitar el provider `epayco_pse` en la
región y volver al placeholder anterior; revertir el envío implica
volver la shipping option a `manual_manual`/tarifa fija — ninguna de
las dos requiere cambios de esquema de datos para revertirse.
- El banner y el buscador no introducen dependencias nuevas ni cambian
ninguna ruta de API — son cambios de storefront puros, reversibles
cambiando los dos componentes de vuelta a su versión anterior.
## Checklist de pruebas reales desde el storefront (pendiente, no ejecutado en este ADR)
Todo lo de arriba se verificó primero a nivel API/backend con llamadas
directas o carritos de prueba armados por API, sin manejar el
storefront real como lo haría un comprador. Esa parte del checklist se
cerró el 2026-09-10 (ver nota de implementación abajo) usando un
navegador real de verdad, no curl.
### RESUELTO (2026-09-10) — navegador real vía Docker en la NAS
El shell donde corren los comandos de esta sesión tiene su propia red
restringida para `npm` (un proxy interno que rompe algunas descargas de
paquetes — no relacionado con la red real del lab). **Docker en la NAS
sí sale a internet real, sin ese filtro** — confirmado en vivo
(`docker pull` normal, un contenedor de Node golpeando
`registry.npmjs.org` sin problema). Se armó un harness reutilizable con
la imagen oficial `mcr.microsoft.com/playwright:v1.51.1-jammy`
(Chromium real) corrida puntualmente contra `shop.cruzcloud.net`,
guardado en `.browser-scratch/` en la raíz del lab (`checks.mjs` +
`README.md` con el comando exacto para reactivarlo — no hace falta
ningún contenedor corriendo de forma persistente, la imagen y las
dependencias ya quedan cacheadas localmente en la NAS).
**Hallazgo no obvio:** el daemon de Docker de la NAS ve un filesystem
distinto al que ve el shell de esta sesión (que corre en su propio
sandbox, aunque monte los mismos repos git) — un volumen `-v` apuntando
a un path de `/tmp` del shell no tira error pero monta un directorio
vacío del lado del contenedor. Hay que usar un path bajo
`/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/` (montaje real
compartido) para que el contenedor vea los archivos de verdad.
**Resultados, con capturas reales:**
1. **Pago PSE completo — FUNCIONA.** Carrito → dirección Bogotá →
envío calculado → banco PSE (sandbox) → **la redirección real
funcionó**: el nombre de campo manejado a la defensiva en
`bank.create()`/`initiatePayment` matcheó al primer intento, y el
navegador llegó a la página real de autorización de PSE
(`registro.desarrollo.pse.com.co`, ambiente de desarrollo/sandbox
de PSE Colombia). No se completó el login bancario (requiere
credenciales de prueba específicas de PSE que no se tenían a mano) —
sigue pendiente confirmar el webhook con un pago sandbox completado
de principio a fin (ver checklist nuevo abajo).
2. **Envío calculado real — FUNCIONA.** El paso de pago mostró
`Envío $8.941` (mismo valor ya visto en la verificación por API de
la sección anterior) en vez del fijo de $15.000, para una dirección
real de Bogotá.
3. **Banner dinámico — funciona, pero destapó un bug real:** un
producto marcado `featured: true` pero **sin stock**
(`available: false`, "Toy Story 5 - Rex") salía primero en el
carrusel con botón "Comprar ahora" hacia una página donde en
realidad no se puede comprar. Corregido en el mismo commit que este
hallazgo (filtro adicional `&& product.available` en
`app/page.tsx`).
4. **Buscador mobile — funciona.** El buscador ya no se ve recortado,
y el autocompletado despliega bien debajo de la fila nueva a todo el
ancho (probado con el viewport de un iPhone 13 real, no devtools).
5. **Bug adicional encontrado, no buscado:** el sandbox real de ePayco
(`bank.getBanks()`) devuelve un primer elemento placeholder
(`bankCode: "0"`, `"A continuación seleccione su banco..."` con el
texto mal decodificado — unicode escapado literal) mezclado con los
bancos reales, mostrado como una opción más, seleccionable, en el
`<select>` del checkout. Ningún banco PSE real tiene código "0" —
se filtra explícitamente en `pse-banks/route.ts`.
### Pendiente — checklist ampliado para la próxima sesión con navegador
3. **Resiliencia del fallback de envío:** forzar una dirección o
producto que haga fallar la cotización real de EnvíoClick (ej. un
DANE code inválido) y confirmar que el checkout sigue avanzando con
el precio de respaldo ($15.000) en vez de romperse — comportamiento
documentado en `calculatePrice()`, nunca ejercitado con un fallo
real.
6. **Webhook de ePayco con un pago real de sandbox completado:**
conseguir credenciales de prueba reales de PSE (persona/clave de
algún banco del ambiente `desarrollo`), completar el login bancario
de punta a punta, y confirmar que el webhook mueve la orden a
completada sin intervención manual — hoy solo se confirmó que el
endpoint responde `200` a un `POST` sintético y que la redirección
al banco ocurre, no el ciclo completo con notificación real.
7. **Login/registro de cliente real** (Google OAuth y con contraseña) —
nunca ejercitado con un navegador real en ninguna sesión.
8. **Bug conocido de Firefox Android** (ver memoria de proyecto,
`known-bug-firefox-android-layout`): el layout se corta/desplaza
solo en Firefox Android — Playwright puede emular Android/Chrome
pero no Firefox; si se necesita reproducir esto específicamente hace
falta `playwright-firefox` o un dispositivo real.
9. **Otros viewports** (tablet, desktop grande) para el banner y el
buscador — solo se probaron mobile (iPhone 13) y desktop 1280px.
10. **Flujo de wishlist/favoritos y el diálogo de cuenta** — no
ejercitados todavía con navegador real.
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.224
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.237
# 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
+11 -1
View File
@@ -18,7 +18,17 @@ export const dynamic = "force-dynamic";
export default async function HomePage() {
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroCarousel /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
// ADR 0010: el banner toma primero productos destacados (curaduria
// explicita del admin via el flag "featured"); si no hay ninguno,
// cae a los mismos "tesoros" (nuevos/destacados) de la seccion de
// abajo para no repetir la logica de seleccion. Se exige ademas
// "available" -- encontrado probando en vivo con un navegador real
// (Playwright): un producto featured pero sin stock disponible salio
// primero en el carrusel con boton "Comprar ahora" hacia una pagina
// donde en realidad no se puede comprar.
const featuredProducts = products.filter((product) => product.featured && product.available);
const bannerProducts = featuredProducts.length > 0 ? featuredProducts : treasures.filter((product) => product.available);
return <><HeroCarousel products={bannerProducts} /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
asset nuevo -- se reconstruye el mismo efecto (fondo punteado +
fotos estilo polaroid) con CSS y fotos reales de producto ya
existentes, en azul/amarillo de marca en vez del violeta original
@@ -33,3 +33,18 @@ data:
EPAYCO_ENV: sandbox
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
# EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen,
# reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY
# va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint
# de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa
# tarifas reales, no tiene modo sandbox. Origen = bodega Bogotá
# ("Bodega principal ARI Shopping", sin dirección física real cargada
# todavía -- ENVIOCLICK_ORIGIN_ADDRESS es un placeholder hasta que la
# haya).
ENVIOCLICK_SANDBOX: "true"
ENVIOCLICK_ORIGIN_DANE_CODE: "11001000"
ENVIOCLICK_ORIGIN_ADDRESS: "Cra 100 #42-65"
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
ENVIOCLICK_ORIGIN_EMAIL: "[email protected]"
ENVIOCLICK_ORIGIN_PHONE: "3014750823"
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.234
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.234
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -47,6 +47,10 @@ stringData:
EPAYCO_PRIVATE_KEY: CAMBIAR
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
EPAYCO_P_KEY: CAMBIAR
# EnvíoClick (ADR 0009 §2) -- API key real de la cuenta
# (envioclickpro.com.co > API Key / Desarrolladores).
ENVIOCLICK_API_KEY: CAMBIAR
---
apiVersion: v1
kind: Secret
@@ -88,6 +88,12 @@ export function CheckoutClient() {
address1: form.address1,
city: selectedCiudad.name,
province: selectedDepartamento.name,
// ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los
// necesita para cotizar sin ambiguedad, el nombre solo no
// alcanza (hay municipios con el mismo nombre en distintos
// departamentos).
cityId: selectedCiudad.id,
departmentId: selectedDepartamento.id,
phone: form.phone,
email: form.email,
});
@@ -4,15 +4,33 @@ import Image from "next/image";
import Link from "next/link";
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
// ADR 0008 sub-fase 4: hero azul con dots de paginacion (carrusel), en vez
// del bento estatico de 3 tarjetas en fucsia/rojo/violeta del esquema
// arcoiris original. Un slide a la vez, fondo azul de marca en los tres,
// para leerse como una sola pieza de marca en vez de 3 bloques de colores
// distintos.
const slides = [
//
// ADR 0010: los slides ya no son un array hardcodeado -- se derivan de
// productos activos reales del catalogo (pasados por prop desde
// app/page.tsx, que ya trae `products` del backend). Fallback estatico
// solo para el caso borde de una tienda sin ningun producto activo.
type Slide = {
id: string;
badge: string;
icon: typeof Sparkles;
title: string;
subtitle: string;
description: string;
href: string;
image: string;
imageAlt: string;
};
const FALLBACK_SLIDES: Slide[] = [
{
id: "celebracion",
badge: "Celebración ARI",
@@ -48,17 +66,46 @@ const slides = [
},
];
const MAX_SLIDES = 5;
const AUTOPLAY_MS = 6000;
export function HeroCarousel() {
function slidesFromProducts(products: Product[]): Slide[] {
return products.slice(0, MAX_SLIDES).map((product) => {
const hasDiscount = product.compareAtPrice !== null && product.compareAtPrice > (product.price ?? 0);
const pctOff = hasDiscount && product.price !== null
? Math.round(100 - (product.price / (product.compareAtPrice as number)) * 100)
: 0;
return {
id: product.id,
badge: hasDiscount ? `${pctOff}% OFF` : product.collection ? `Colección ${product.collection}` : "Destacado",
icon: hasDiscount ? BadgePercent : Sparkles,
title: product.name,
subtitle: product.priceLabel,
description: product.shortDescription || "Descúbrelo en la tienda ARI Shopping.",
href: `/product/${product.slug}`,
image: product.image,
imageAlt: product.name,
};
});
}
export function HeroCarousel({ products }: { products: Product[] }) {
const reduceMotion = useReducedMotion();
const [index, setIndex] = useState(0);
const slides = useMemo(() => {
const fromProducts = slidesFromProducts(products);
return fromProducts.length > 0 ? fromProducts : FALLBACK_SLIDES;
}, [products]);
useEffect(() => {
if (reduceMotion) return;
const timer = setInterval(() => setIndex((current) => (current + 1) % slides.length), AUTOPLAY_MS);
return () => clearInterval(timer);
}, [reduceMotion]);
}, [reduceMotion, slides.length]);
useEffect(() => {
if (index >= slides.length) setIndex(0);
}, [slides.length, index]);
const slide = slides[index];
if (!slide) return null;
@@ -55,34 +55,47 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<div className="relative overflow-visible bg-white">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
<div className="mx-auto grid max-w-7xl grid-cols-[auto_1fr_auto] items-center gap-4 px-4 py-4 lg:gap-8">
{/* Isotipo circular con icono + wordmark integrados (como en la
version previa a ADR 0008), pedido explicito del usuario en
vez del lockup rectangular. logo-ari.svg (rectangular) se
mantiene para el footer. */}
<Link href="/" aria-label="ARI shopping" className="shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
</Link>
<PredictiveSearch />
<div className="flex items-center gap-2">
{/* Antes escondido en mobile (solo alcanzable desde el menu
hamburguesa) -- pedido explicito del usuario: visible
siempre en el header, con un punto verde cuando hay sesion
iniciada (mismo patron que el badge de cantidad del
carrito/favoritos, pero de presencia, no de conteo). */}
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
<Heart />
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
</Button>
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
<ShoppingCart />
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
</Button>
{/* ADR 0010: en mobile/tablet el buscador se movio a una segunda
fila a todo el ancho, debajo de logo+iconos (antes competia por
espacio en una sola fila con el grid_[auto_1fr_auto] y quedaba
angosto/recortado). En desktop (lg+) se mantiene la fila unica
original -- ahi sobra espacio y partirla en dos solo alargaria
el header sin necesidad. */}
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
{/* Isotipo circular con icono + wordmark integrados (como en la
version previa a ADR 0008), pedido explicito del usuario en
vez del lockup rectangular. logo-ari.svg (rectangular) se
mantiene para el footer. */}
<Link href="/" aria-label="ARI shopping" className="shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
</Link>
<div className="hidden lg:block">
<PredictiveSearch />
</div>
<div className="flex items-center gap-2">
{/* Antes escondido en mobile (solo alcanzable desde el menu
hamburguesa) -- pedido explicito del usuario: visible
siempre en el header, con un punto verde cuando hay sesion
iniciada (mismo patron que el badge de cantidad del
carrito/favoritos, pero de presencia, no de conteo). */}
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
<UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
<Heart />
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
</Button>
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
<ShoppingCart />
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
</Button>
</div>
</div>
<div className="mt-3 lg:hidden">
<PredictiveSearch />
</div>
</div>
</div>
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.230
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.238
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -46,6 +46,14 @@ export interface ShippingAddressInput {
province?: string;
phone: string;
email: string;
// ids DIVIPOLA/DANE reales (lib/data/colombia.ts) -- se guardan en la
// metadata de la direccion porque Medusa no tiene un campo propio para
// esto. EnvioClick (fulfillment provider real, ver ADR 0009 §2) los
// necesita para cotizar/generar guia sin ambiguedad -- el nombre de
// ciudad solo no alcanza (hay nombres de municipio repetidos entre
// distintos departamentos en Colombia).
cityId?: number;
departmentId?: number;
}
export interface ShippingOption {
@@ -212,6 +220,10 @@ export async function setShippingAddress(
province: address.province || undefined,
country_code: "co",
phone: address.phone,
metadata:
address.cityId || address.departmentId
? { cityId: address.cityId, departmentId: address.departmentId }
: undefined,
},
},
});
+54 -41
View File
@@ -16,7 +16,7 @@
"clsx": "2.1.1",
"lucide-react": "0.544.0",
"motion": "12.23.24",
"next": "15.5.10",
"next": "15.5.24",
"react": "19.1.5",
"react-dom": "19.1.5",
"tailwind-merge": "3.3.1",
@@ -728,9 +728,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.10",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/env/-/env-15.5.10.tgz",
"integrity": "sha512-plg+9A/KoZcTS26fe15LHg+QxReTazrIOoKKUC3Uz4leGGeNPgLHdevVraAAOX0snnUs3WkRx3eUQpj9mreG6A==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.24.tgz",
"integrity": "sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -744,9 +744,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz",
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.24.tgz",
"integrity": "sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==",
"cpu": [
"arm64"
],
@@ -760,9 +760,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz",
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.24.tgz",
"integrity": "sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==",
"cpu": [
"x64"
],
@@ -776,12 +776,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz",
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.24.tgz",
"integrity": "sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -792,12 +795,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz",
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.24.tgz",
"integrity": "sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -808,12 +814,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz",
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.24.tgz",
"integrity": "sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -824,12 +833,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz",
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.24.tgz",
"integrity": "sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -840,9 +852,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz",
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.24.tgz",
"integrity": "sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==",
"cpu": [
"arm64"
],
@@ -856,9 +868,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.7",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz",
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.24.tgz",
"integrity": "sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==",
"cpu": [
"x64"
],
@@ -5394,11 +5406,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.10",
"resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/next/-/next-15.5.10.tgz",
"integrity": "sha512-r0X65PNwyDDyOrWNKpQoZvOatw7BcsTPRKdwEqtc9cj3wv7mbBIk9tKed4klRaFXJdX0rugpuMTHslDrAU1bBg==",
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.24.tgz",
"integrity": "sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.10",
"@next/env": "15.5.24",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5411,15 +5424,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.7",
"@next/swc-darwin-x64": "15.5.7",
"@next/swc-linux-arm64-gnu": "15.5.7",
"@next/swc-linux-arm64-musl": "15.5.7",
"@next/swc-linux-x64-gnu": "15.5.7",
"@next/swc-linux-x64-musl": "15.5.7",
"@next/swc-win32-arm64-msvc": "15.5.7",
"@next/swc-win32-x64-msvc": "15.5.7",
"sharp": "^0.34.3"
"@next/swc-darwin-arm64": "15.5.24",
"@next/swc-darwin-x64": "15.5.24",
"@next/swc-linux-arm64-gnu": "15.5.24",
"@next/swc-linux-arm64-musl": "15.5.24",
"@next/swc-linux-x64-gnu": "15.5.24",
"@next/swc-linux-x64-musl": "15.5.24",
"@next/swc-win32-arm64-msvc": "15.5.24",
"@next/swc-win32-x64-msvc": "15.5.24",
"sharp": "^0.34.3 || ^0.35.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
+1 -1
View File
@@ -19,7 +19,7 @@
"clsx": "2.1.1",
"lucide-react": "0.544.0",
"motion": "12.23.24",
"next": "15.5.10",
"next": "15.5.24",
"react": "19.1.5",
"react-dom": "19.1.5",
"tailwind-merge": "3.3.1",