fase 1
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m23s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 15m12s

This commit is contained in:
2026-07-31 23:27:01 -05:00
parent 4b85fccc97
commit aa857bc2ae
22 changed files with 1450 additions and 19 deletions
+117
View File
@@ -0,0 +1,117 @@
name: Build and Push Medusa
on:
push:
branches:
- main
paths:
- 'workloads/commerce-backend/**'
- '.gitea/workflows/build-medusa.yaml'
permissions:
contents: write
packages: write
jobs:
build:
name: Construir y publicar Medusa
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout del código
uses: actions/checkout@v3
with:
fetch-depth: 1
persist-credentials: true
- name: Definir versión
id: vars
shell: bash
run: |
set -euo pipefail
echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- name: Validar secretos del Registry
shell: bash
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
test -n "${REGISTRY_USER}" || {
echo "ERROR: REGISTRY_USER no está configurado."
exit 1
}
test -n "${REGISTRY_PASSWORD}" || {
echo "ERROR: REGISTRY_PASSWORD no está configurado."
exit 1
}
- name: Login en Gitea Registry
uses: docker/login-action@v2
with:
registry: gitea.cruzcloud.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
- name: Construir y subir imagen
uses: docker/build-push-action@v4
with:
context: workloads/commerce-backend/
file: workloads/commerce-backend/Dockerfile
push: true
tags: |
gitea.cruzcloud.net/devops/ecommerce-medusa:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-medusa:latest
- name: Verificar promoción segura
id: promotion
shell: bash
run: |
set -euo pipefail
git fetch origin main
CURRENT_SHA="${{ github.sha }}"
REMOTE_SHA="$(git rev-parse origin/main)"
if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then
echo "promote=true" >> "$GITHUB_OUTPUT"
else
echo "promote=false" >> "$GITHUB_OUTPUT"
echo "Hay un commit más reciente; no se actualizará el manifiesto."
fi
- name: Actualizar manifiesto Medusa
if: steps.promotion.outputs.promote == 'true'
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}"
MANIFEST="workloads/ecommerce/commerce/medusa.yaml"
IMAGE="gitea.cruzcloud.net/devops/ecommerce-medusa"
git config user.name "gitea-actions"
git config user.email "[email protected]"
git fetch origin main
git checkout -B main origin/main
sed -i -E \
"s|(${IMAGE}:)v[0-9]+\.[0-9]+\.[0-9]+|\1${VERSION}|g" \
"${MANIFEST}"
git add "${MANIFEST}"
if git diff --cached --quiet; then
echo "El manifiesto ya apunta a ${VERSION}."
exit 0
fi
git commit \
-m "chore(gitops): deploy Medusa ${VERSION} [skip ci]"
git push origin HEAD:main
+10
View File
@@ -0,0 +1,10 @@
node_modules
.medusa
.cache
.git
.env
.env.*
npm-debug.log*
README.md
data
migration
+3
View File
@@ -0,0 +1,3 @@
fund=false
audit=false
legacy-peer-deps=true
+39
View File
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1.7
FROM node:22.18.0-bookworm-slim AS builder
WORKDIR /app
ENV CI=true
ENV NODE_ENV=development
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
COPY package.json .npmrc ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm install --no-audit --no-fund
COPY tsconfig.json medusa-config.ts ./
COPY src ./src
RUN npm run build
FROM node:22.18.0-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=9000
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
COPY --from=builder /app/.medusa/server/package.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm install --omit=dev --no-audit --no-fund
COPY --from=builder /app/.medusa/server ./
RUN chown -R node:node /app
USER node
EXPOSE 9000
CMD ["npx", "medusa", "start"]
+20
View File
@@ -0,0 +1,20 @@
# ARI Shopping Commerce Backend
Backend Medusa v2.18 para la Fase 1.
## Capacidades iniciales
- Medusa Admin en `/app`.
- Catálogo, variantes y precios.
- Imágenes en almacenamiento S3 compatible.
- PostgreSQL y Redis externos.
- Modo `shared` para una sola réplica durante la Fase 1.
- Migraciones ejecutadas por el `initContainer` de Kubernetes.
La imagen se construye mediante:
```text
.gitea/workflows/build-medusa.yaml
```
No se deben colocar secretos en este directorio.
@@ -0,0 +1,66 @@
import { defineConfig, loadEnv } from "@medusajs/framework/utils"
loadEnv(process.env.NODE_ENV || "development", process.cwd())
const required = (name: string): string => {
const value = process.env[name]
if (!value) {
throw new Error(`La variable ${name} es obligatoria.`)
}
return value
}
module.exports = defineConfig({
projectConfig: {
databaseUrl: required("DATABASE_URL"),
redisUrl: required("REDIS_URL"),
workerMode: (process.env.MEDUSA_WORKER_MODE || "shared") as
| "shared"
| "worker"
| "server",
http: {
storeCors: required("STORE_CORS"),
adminCors: required("ADMIN_CORS"),
authCors: required("AUTH_CORS"),
jwtSecret: required("JWT_SECRET"),
cookieSecret: required("COOKIE_SECRET"),
},
},
admin: {
disable: process.env.DISABLE_MEDUSA_ADMIN === "true",
backendUrl:
process.env.MEDUSA_BACKEND_URL ||
"https://commerce.cruzcloud.net",
storefrontUrl:
process.env.STOREFRONT_URL ||
"https://shop.cruzcloud.net",
},
modules: [
{
resolve: "@medusajs/medusa/file",
options: {
providers: [
{
resolve: "@medusajs/medusa/file-s3",
id: "s3",
options: {
file_url: required("S3_FILE_URL"),
access_key_id: required("S3_ACCESS_KEY_ID"),
secret_access_key: required("S3_SECRET_ACCESS_KEY"),
region: process.env.S3_REGION || "us-east-1",
bucket: required("S3_BUCKET"),
endpoint: required("S3_ENDPOINT"),
cache_control:
"public, max-age=31536000, immutable",
additional_client_config: {
forcePathStyle: true,
},
},
},
],
},
},
],
})
+32
View File
@@ -0,0 +1,32 @@
{
"name": "ari-shopping-commerce-backend",
"version": "1.0.0",
"private": true,
"description": "Backend de catálogo dinámico ARI Shopping sobre Medusa v2.",
"license": "UNLICENSED",
"scripts": {
"build": "medusa build",
"start": "medusa start",
"dev": "medusa develop",
"predeploy": "medusa db:migrate"
},
"dependencies": {
"@medusajs/admin-sdk": "2.18.0",
"@medusajs/cli": "2.18.0",
"@medusajs/framework": "2.18.0",
"@medusajs/medusa": "2.18.0"
},
"devDependencies": {
"@swc/core": "^1.7.28",
"@types/node": "^20.12.11",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.2.25",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.14"
},
"engines": {
"node": ">=20"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2021",
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "Node16",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": false,
"sourceMap": false,
"inlineSourceMap": true,
"outDir": "./.medusa/server",
"rootDir": "./",
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"checkJs": false,
"strictNullChecks": true
},
"ts-node": {
"swc": true
},
"include": [
"**/*",
".medusa/types/*"
],
"exclude": [
"node_modules",
".medusa/server",
".medusa/admin",
".cache"
]
}
@@ -0,0 +1,23 @@
# Cambios a `workloads/ecommerce/kustomization.yaml`
El instalador incluido agrega solamente el backend de la Fase 1:
```yaml
resources:
- commerce/
```
No activa automáticamente Medusa en el frontend.
Después de validar el backend, agrega este patch al bloque `patches`:
```yaml
- path: patches/frontend-medusa-env.yaml
target:
group: apps
version: v1
kind: Deployment
name: frontend-deploy
```
El catálogo `mock` seguirá funcionando hasta realizar este commit de corte.
@@ -0,0 +1,21 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: commerce-config
data:
NODE_ENV: production
PORT: "9000"
STORE_CORS: https://shop.cruzcloud.net
ADMIN_CORS: https://commerce.cruzcloud.net
AUTH_CORS: https://commerce.cruzcloud.net,https://shop.cruzcloud.net
MEDUSA_BACKEND_URL: https://commerce.cruzcloud.net
STOREFRONT_URL: https://shop.cruzcloud.net
MEDUSA_WORKER_MODE: shared
DISABLE_MEDUSA_ADMIN: "false"
S3_FILE_URL: https://media.cruzcloud.net/ari-products
S3_ENDPOINT: http://minio-svc:9000
S3_REGION: us-east-1
S3_BUCKET: ari-products
@@ -0,0 +1,30 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: commerce-ingress
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
ingressClassName: traefik
rules:
- host: commerce.cruzcloud.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: medusa-svc
port:
number: 9000
- host: media.cruzcloud.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio-svc
port:
number: 9000
@@ -0,0 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap.yaml
- postgres.yaml
- redis.yaml
- minio.yaml
- minio-bootstrap-job.yaml
- medusa.yaml
- ingress-commerce.yaml
+90
View File
@@ -0,0 +1,90 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: medusa-deploy
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: medusa
template:
metadata:
labels:
app: medusa
spec:
imagePullSecrets:
- name: gitea-registry-secret
initContainers:
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.0
imagePullPolicy: IfNotPresent
command:
- npx
- medusa
- db:migrate
envFrom:
- configMapRef:
name: commerce-config
- secretRef:
name: commerce-secrets
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: 500m
memory: 768Mi
containers:
- name: medusa
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.0
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: commerce-config
- secretRef:
name: commerce-secrets
ports:
- name: http
containerPort: 9000
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 18
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 60
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 150m
memory: 384Mi
limits:
cpu: 750m
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: medusa-svc
spec:
selector:
app: medusa
ports:
- name: http
port: 9000
targetPort: 9000
@@ -0,0 +1,54 @@
apiVersion: batch/v1
kind: Job
metadata:
name: commerce-minio-bootstrap
spec:
backoffLimit: 12
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: commerce-minio-bootstrap
spec:
restartPolicy: OnFailure
containers:
- name: configure
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
imagePullPolicy: IfNotPresent
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: commerce-secrets
key: MINIO_ROOT_USER
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: commerce-secrets
key: MINIO_ROOT_PASSWORD
command:
- sh
- -c
args:
- |
set -eu
until mc alias set local \
http://minio-svc:9000 \
"$MINIO_ROOT_USER" \
"$MINIO_ROOT_PASSWORD"; do
echo "Esperando MinIO..."
sleep 5
done
mc mb --ignore-existing local/ari-products
mc anonymous set download local/ari-products
echo "Bucket ari-products creado y habilitado para lectura pública."
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
+92
View File
@@ -0,0 +1,92 @@
apiVersion: v1
kind: Service
metadata:
name: minio-svc
spec:
selector:
app: commerce-minio
ports:
- name: s3
port: 9000
targetPort: 9000
- name: console
port: 9001
targetPort: 9001
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: commerce-minio
spec:
serviceName: minio-svc
replicas: 1
selector:
matchLabels:
app: commerce-minio
template:
metadata:
labels:
app: commerce-minio
spec:
terminationGracePeriodSeconds: 60
containers:
- name: minio
image: quay.io/minio/minio:RELEASE.2025-10-15T17-29-55Z
imagePullPolicy: IfNotPresent
args:
- server
- /data
- --console-address
- ":9001"
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: commerce-secrets
key: MINIO_ROOT_USER
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: commerce-secrets
key: MINIO_ROOT_PASSWORD
ports:
- name: s3
containerPort: 9000
- name: console
containerPort: 9001
readinessProbe:
httpGet:
path: /minio/health/ready
port: s3
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet:
path: /minio/health/live
port: s3
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 768Mi
volumeMounts:
- name: minio-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: minio-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 20Gi
@@ -0,0 +1,94 @@
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
spec:
clusterIP: None
selector:
app: commerce-postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: commerce-postgres
spec:
serviceName: postgres-svc
replicas: 1
selector:
matchLabels:
app: commerce-postgres
template:
metadata:
labels:
app: commerce-postgres
spec:
terminationGracePeriodSeconds: 60
containers:
- name: postgres
image: postgres:17.10-alpine3.24
imagePullPolicy: IfNotPresent
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: commerce-secrets
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: commerce-secrets
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: commerce-secrets
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 768Mi
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 10Gi
+91
View File
@@ -0,0 +1,91 @@
apiVersion: v1
kind: Service
metadata:
name: redis-svc
spec:
clusterIP: None
selector:
app: commerce-redis
ports:
- name: redis
port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: commerce-redis
spec:
serviceName: redis-svc
replicas: 1
selector:
matchLabels:
app: commerce-redis
template:
metadata:
labels:
app: commerce-redis
spec:
terminationGracePeriodSeconds: 30
containers:
- name: redis
image: redis:7.4-alpine
imagePullPolicy: IfNotPresent
command:
- sh
- -c
args:
- >
exec redis-server
--appendonly yes
--appendfsync everysec
--requirepass "$REDIS_PASSWORD"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: commerce-secrets
key: REDIS_PASSWORD
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command:
- sh
- -c
- redis-cli -a "$REDIS_PASSWORD" ping | grep PONG
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
livenessProbe:
exec:
command:
- sh
- -c
- redis-cli -a "$REDIS_PASSWORD" ping | grep PONG
initialDelaySeconds: 20
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 6
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
@@ -0,0 +1,32 @@
# NO agregar este archivo al kustomization ni subir valores reales a Gitea.
# Utiliza scripts/create-commerce-secrets.sh o reemplázalo por Sealed Secrets.
apiVersion: v1
kind: Secret
metadata:
name: commerce-secrets
type: Opaque
stringData:
POSTGRES_USER: medusa
POSTGRES_PASSWORD: CAMBIAR
POSTGRES_DB: medusa
DATABASE_URL: postgresql://medusa:CAMBIAR@postgres-svc:5432/medusa
REDIS_PASSWORD: CAMBIAR
REDIS_URL: redis://:CAMBIAR@redis-svc:6379
MINIO_ROOT_USER: ari-minio
MINIO_ROOT_PASSWORD: CAMBIAR
S3_ACCESS_KEY_ID: ari-minio
S3_SECRET_ACCESS_KEY: CAMBIAR
JWT_SECRET: CAMBIAR
COOKIE_SECRET: CAMBIAR
---
apiVersion: v1
kind: Secret
metadata:
name: commerce-storefront
type: Opaque
stringData:
MEDUSA_PUBLISHABLE_KEY: pk_CAMBIAR_DESPUES_DE_CREARLA_EN_MEDUSA
@@ -1,31 +1,563 @@
import type { CatalogProvider, Product } from "@/lib/headless/types"; import type {
CatalogProvider,
Product,
ProductFilters,
ProductVariant,
} from "@/lib/headless/types";
async function medusaFetch<T>(path: string): Promise<T> { type MedusaImage = {
const baseUrl = process.env.MEDUSA_BACKEND_URL; url?: string | null;
if (!baseUrl) throw new Error("Medusa no está configurado."); };
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
headers: process.env.MEDUSA_PUBLISHABLE_KEY type MedusaTag = {
? { "x-publishable-api-key": process.env.MEDUSA_PUBLISHABLE_KEY } value?: string | null;
};
type MedusaCategory = {
name?: string | null;
};
type MedusaCollection = {
title?: string | null;
};
type MedusaCalculatedPrice = {
calculated_amount?: number | null;
original_amount?: number | null;
currency_code?: string | null;
};
type MedusaVariant = {
id: string;
title?: string | null;
sku?: string | null;
manage_inventory?: boolean | null;
allow_backorder?: boolean | null;
inventory_quantity?: number | null;
calculated_price?: MedusaCalculatedPrice | null;
options?: Array<{
value?: string | null;
option?: {
title?: string | null;
} | null;
}> | null;
};
type MedusaProduct = {
id: string;
handle?: string | null;
title?: string | null;
subtitle?: string | null;
description?: string | null;
thumbnail?: string | null;
external_id?: string | null;
metadata?: Record<string, unknown> | null;
images?: MedusaImage[] | null;
tags?: MedusaTag[] | null;
categories?: MedusaCategory[] | null;
collection?: MedusaCollection | null;
variants?: MedusaVariant[] | null;
};
type MedusaProductsResponse = {
products: MedusaProduct[];
count?: number;
limit?: number;
offset?: number;
};
type LegacyMeta = {
category: string;
collection: string;
brand: string;
ageRange: string;
featured: boolean;
isNew: boolean;
fallbackImage: string;
fallbackThumbnail: string;
};
const legacyProductMeta: Record<string, LegacyMeta> = {
"toy-story-5-rex": {
"category": "Figuras",
"collection": "Toy Story",
"brand": "Disney Pixar",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-001-toy-story-5-rex.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-001-toy-story-5-rex.webp"
},
"toy-story-5-buzz-lightyear": {
"category": "Figuras",
"collection": "Toy Story",
"brand": "Disney Pixar",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-002-toy-story-5-buzz-lightyear.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
},
"toy-story-5-jessie": {
"category": "Figuras",
"collection": "Toy Story",
"brand": "Disney Pixar",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-003-toy-story-5-jessie.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-003-toy-story-5-jessie.webp"
},
"toy-story-5-woody": {
"category": "Figuras",
"collection": "Toy Story",
"brand": "Disney Pixar",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-004-toy-story-5-woody.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-004-toy-story-5-woody.webp"
},
"intensamente-furia": {
"category": "Peluches",
"collection": "Intensamente",
"brand": "Disney",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-005-intensamente-furia.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-005-intensamente-furia.webp"
},
"intensamente-tristeza": {
"category": "Peluches",
"collection": "Intensamente",
"brand": "Disney",
"ageRange": "3+",
"featured": true,
"isNew": true,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-006-intensamente-tristeza.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-006-intensamente-tristeza.webp"
},
"minnie-y-mickey-mouse": {
"category": "Peluches",
"collection": "Mickey & Minnie",
"brand": "Disney",
"ageRange": "3+",
"featured": true,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-007-minnie-y-mickey-mouse.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
},
"angel-lilo-y-stitch": {
"category": "Peluches",
"collection": "Lilo & Stitch",
"brand": "Disney",
"ageRange": "3+",
"featured": true,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-008-angel-lilo-y-stitch.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
},
"mochilas-disney-coleccion-marvel": {
"category": "Mochilas",
"collection": "Marvel",
"brand": "Marvel",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-009-mochilas-disney-coleccion-marvel.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
},
"mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa": {
"category": "Mochilas",
"collection": "Frozen",
"brand": "Disney",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
},
"mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera": {
"category": "Mochilas",
"collection": "Marvel",
"brand": "Marvel",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
},
"disney-mochila-de-poliester-ultraligera-y-transpirable-de-gran-tamano-y-multiples-colores-dimension": {
"category": "Mochilas",
"collection": "Disney",
"brand": "Disney",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-012-disney-mochila-de-poliester-ultraligera-.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
},
"mochila-pequena-bob-esponja": {
"category": "Mochilas",
"collection": "Bob Esponja",
"brand": "Disney",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-013-mochila-pequena-bob-esponja.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
},
"mochila-casual-de-gran-capacidad-con-estampado-de-bob-esponja-unisex-bolsa-escolar-de-dibujos-anim": {
"category": "Mochilas",
"collection": "Bob Esponja",
"brand": "Disney",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-014-mochila-casual-de-gran-capacidad-con-est.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
},
"mochila-escolar-estilo-britanico-de-disney-con-proteccion-de-la-columna-vertebral-nueva-mochila-de": {
"category": "Mochilas",
"collection": "Disney",
"brand": "Disney",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-015-mochila-escolar-estilo-britanico-de-disn.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
},
"disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x": {
"category": "Mochilas",
"collection": "Marvel",
"brand": "Marvel",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
},
"mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par": {
"category": "Mochilas",
"collection": "Marvel",
"brand": "Marvel",
"ageRange": "6+",
"featured": false,
"isNew": false,
"fallbackImage": "https://shop.cruzcloud.net/images/products/ari-017-mochila-de-disney-marvel-capitan-america.webp",
"fallbackThumbnail": "https://shop.cruzcloud.net/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
}
};
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) {
throw new Error("MEDUSA_BACKEND_URL no está configurado.");
}
return value.replace(/\/$/, "");
}
async function medusaFetch<T>(
path: string,
searchParams?: URLSearchParams,
): Promise<T> {
const url = new URL(`${backendUrl()}${path}`);
if (searchParams) {
searchParams.forEach((value, key) => {
url.searchParams.append(key, value);
});
}
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(url, {
headers: publishableKey
? { "x-publishable-api-key": publishableKey }
: undefined, : undefined,
next: { revalidate: 300 }, next: {
revalidate: 60,
tags: ["medusa-products"],
},
}); });
if (!response.ok) throw new Error(`Medusa respondió HTTP ${response.status}`);
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`Medusa respondió HTTP ${response.status}: ${body.slice(0, 300)}`,
);
}
return (await response.json()) as T; return (await response.json()) as T;
} }
function notImplemented(): never { function stringMetadata(
throw new Error("Completa el mapeo Medusa → Product en lib/headless/providers/medusa.ts."); metadata: Record<string, unknown> | null | undefined,
key: string,
): string {
const value = metadata?.[key];
return typeof value === "string" ? value : "";
}
function booleanMetadata(
metadata: Record<string, unknown> | null | undefined,
key: string,
): boolean {
const value = metadata?.[key];
return value === true || value === "true";
}
function normalizeCurrency(value?: string | null): string {
return (value || "COP").toUpperCase();
}
function mapVariant(variant: MedusaVariant): ProductVariant {
const option = variant.options?.[0];
const available =
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
return {
id: variant.id,
name: option?.option?.title || "Presentación",
value: option?.value || variant.title || "Estándar",
available,
};
}
function mapProduct(product: MedusaProduct): Product {
const slug = product.handle || product.id;
const legacy = legacyProductMeta[slug];
const variant = product.variants?.[0];
const calculated = variant?.calculated_price;
const category =
product.categories?.[0]?.name ||
stringMetadata(product.metadata, "category") ||
legacy?.category ||
"Variedades";
const collection =
product.collection?.title ||
stringMetadata(product.metadata, "collection") ||
legacy?.collection ||
"";
const brand =
stringMetadata(product.metadata, "brand") ||
legacy?.brand ||
"ARI Shopping";
const ageRange =
stringMetadata(product.metadata, "ageRange") ||
legacy?.ageRange ||
"Todas";
const images = [
...(product.images || [])
.map((image) => image.url || "")
.filter(Boolean),
];
if (images.length === 0 && legacy?.fallbackImage) {
images.push(legacy.fallbackImage);
}
const thumbnail =
product.thumbnail ||
images[0] ||
legacy?.fallbackThumbnail ||
"/images/brand/logo-ari.png";
const price =
typeof calculated?.calculated_amount === "number"
? calculated.calculated_amount
: null;
const original =
typeof calculated?.original_amount === "number"
? calculated.original_amount
: null;
const compareAtPrice =
original !== null && price !== null && original > price
? original
: null;
const available =
!variant ||
variant.allow_backorder === true ||
variant.manage_inventory === false ||
(variant.inventory_quantity ?? 0) > 0;
const tags = (product.tags || [])
.map((tag) => tag.value || "")
.filter(Boolean);
return {
id: product.external_id || product.id,
slug,
name: product.title || "Producto ARI Shopping",
shortDescription:
product.subtitle ||
product.title ||
"Producto ARI Shopping",
description:
product.description ||
"Producto disponible para confirmación de inventario con ARI Shopping.",
category,
collection,
brand,
ageRange,
price,
compareAtPrice,
currency: normalizeCurrency(calculated?.currency_code),
priceLabel: price === null ? "Consultar disponibilidad" : "",
available,
featured:
booleanMetadata(product.metadata, "featured") ||
legacy?.featured ||
false,
isNew:
booleanMetadata(product.metadata, "isNew") ||
legacy?.isNew ||
false,
image: images[0] || thumbnail,
thumbnail,
images: images.length > 0 ? images : [thumbnail],
tags,
variants: (product.variants || []).map(mapVariant),
};
}
function matchesFilters(
product: Product,
filters?: ProductFilters,
): boolean {
if (!filters) return true;
const query = filters.query?.trim().toLocaleLowerCase("es");
if (
query &&
![
product.name,
product.shortDescription,
product.description,
product.category,
product.collection,
product.brand,
...product.tags,
]
.join(" ")
.toLocaleLowerCase("es")
.includes(query)
) {
return false;
}
if (
filters.category &&
filters.category !== "Todos" &&
product.category !== filters.category
) {
return false;
}
if (
filters.collection &&
product.collection !== filters.collection
) {
return false;
}
if (
filters.brand &&
filters.brand !== "Todas" &&
product.brand !== filters.brand
) {
return false;
}
if (
filters.ageRange &&
filters.ageRange !== "Todas" &&
product.ageRange !== filters.ageRange
) {
return false;
}
if (
typeof filters.minPrice === "number" &&
product.price !== null &&
product.price < filters.minPrice
) {
return false;
}
if (
typeof filters.maxPrice === "number" &&
product.price !== null &&
product.price > filters.maxPrice
) {
return false;
}
return true;
}
async function fetchProducts(
filters?: ProductFilters,
limit = 100,
): Promise<Product[]> {
const params = new URLSearchParams({
limit: String(limit),
currency_code: "cop",
fields:
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
});
if (filters?.query) {
params.set("q", filters.query);
}
const response = await medusaFetch<MedusaProductsResponse>(
"/store/products",
params,
);
return response.products
.map(mapProduct)
.filter((product) => matchesFilters(product, filters));
} }
export const medusaCatalogProvider: CatalogProvider = { export const medusaCatalogProvider: CatalogProvider = {
async getProducts() { async getProducts(filters) {
await medusaFetch<unknown>("/store/products?limit=24"); return fetchProducts(filters);
return notImplemented();
}, },
async getProductBySlug(): Promise<Product | null> {
return notImplemented(); async getProductBySlug(slug) {
const params = new URLSearchParams({
handle: slug,
limit: "1",
currency_code: "cop",
fields:
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
});
const response = await medusaFetch<MedusaProductsResponse>(
"/store/products",
params,
);
const product = response.products[0];
return product ? mapProduct(product) : null;
}, },
async searchProducts(): Promise<Product[]> {
return notImplemented(); async searchProducts(query, limit = 8) {
return fetchProducts({ query }, limit);
}, },
}; };
+19 -1
View File
@@ -10,6 +10,24 @@ const nextConfig: NextConfig = {
minimumCacheTTL: 604800, minimumCacheTTL: 604800,
deviceSizes: [360, 640, 768, 1024, 1280, 1536], deviceSizes: [360, 640, 768, 1024, 1280, 1536],
imageSizes: [64, 96, 160, 256, 384, 560], imageSizes: [64, 96, 160, 256, 384, 560],
remotePatterns: [
{
protocol: "https",
hostname: "media.cruzcloud.net",
pathname: "/ari-products/**",
},
{
protocol: "https",
hostname: "shop.cruzcloud.net",
pathname: "/images/products/**",
},
{
protocol: "https",
hostname: "shop.cruzcloud.net",
pathname: "/images/thumbnails/**",
},
],
}, },
async headers() { async headers() {
@@ -28,4 +46,4 @@ const nextConfig: NextConfig = {
}, },
}; };
export default nextConfig; export default nextConfig;
@@ -0,0 +1,21 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deploy
spec:
template:
spec:
containers:
- name: web
env:
- name: HEADLESS_PROVIDER
value: medusa
- name: MEDUSA_BACKEND_URL
value: http://medusa-svc:9000
- name: MEDUSA_PUBLISHABLE_KEY
valueFrom:
secretKeyRef:
name: commerce-storefront
key: MEDUSA_PUBLISHABLE_KEY