From 54d2f208ae8002c22ff950ac3610e906e9c0dd02 Mon Sep 17 00:00:00 2001 From: Cristian Cruz Date: Sat, 18 Jul 2026 16:34:01 -0500 Subject: [PATCH] feat(ecommerce): release Ari Shopping Frontend v2 --- .gitea/workflows/build.yaml | 481 +----------------- .gitea/workflows/catalog-sync.yaml | 22 + .gitignore | 0 workloads/ecommerce/.dockerignore | 4 + workloads/ecommerce/Dockerfile | 14 +- workloads/ecommerce/README.md | 59 ++- workloads/ecommerce/assets/css/styles.css | 5 + .../ecommerce/assets/img/brand/favicon.svg | 1 + workloads/ecommerce/assets/img/brand/logo.svg | 1 + .../ecommerce/assets/img/products/ari-001.svg | 1 + .../ecommerce/assets/img/products/ari-002.svg | 1 + .../ecommerce/assets/img/products/ari-003.svg | 1 + .../ecommerce/assets/img/products/ari-004.svg | 1 + .../ecommerce/assets/img/products/ari-005.svg | 1 + .../ecommerce/assets/img/products/ari-006.svg | 1 + .../ecommerce/assets/img/products/ari-007.svg | 1 + .../ecommerce/assets/img/products/ari-008.svg | 1 + workloads/ecommerce/assets/js/app.js | 1 + workloads/ecommerce/catalog-source.csv | 9 + workloads/ecommerce/data/products.json | 191 +++++++ workloads/ecommerce/data/store.json | 10 + workloads/ecommerce/index.html | 98 +--- workloads/ecommerce/nginx.conf | 1 + workloads/ecommerce/tools/catalog_sync.py | 36 ++ workloads/ecommerce/tools/requirements.txt | 1 + 25 files changed, 376 insertions(+), 566 deletions(-) create mode 100644 .gitea/workflows/catalog-sync.yaml create mode 100644 .gitignore create mode 100644 workloads/ecommerce/.dockerignore create mode 100644 workloads/ecommerce/assets/css/styles.css create mode 100644 workloads/ecommerce/assets/img/brand/favicon.svg create mode 100644 workloads/ecommerce/assets/img/brand/logo.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-001.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-002.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-003.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-004.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-005.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-006.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-007.svg create mode 100644 workloads/ecommerce/assets/img/products/ari-008.svg create mode 100644 workloads/ecommerce/assets/js/app.js create mode 100644 workloads/ecommerce/catalog-source.csv create mode 100644 workloads/ecommerce/data/products.json create mode 100644 workloads/ecommerce/data/store.json create mode 100644 workloads/ecommerce/nginx.conf create mode 100644 workloads/ecommerce/tools/catalog_sync.py create mode 100644 workloads/ecommerce/tools/requirements.txt diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 34dd451..407d317 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -1,33 +1,29 @@ -name: Build, Push and Validate Frontend +name: Build and Push Frontend on: push: branches: - main paths: - - 'workloads/ecommerce/index.html' - - 'workloads/ecommerce/Dockerfile' + - 'workloads/ecommerce/**' - '.gitea/workflows/build.yaml' jobs: build: runs-on: ubuntu-latest - + timeout-minutes: 20 # Aumentamos a 20 min para evitar que se corte el clone steps: - name: Checkout del código uses: actions/checkout@v3 with: - fetch-depth: 0 + fetch-depth: 1 # IMPORTANTE: Solo descarga el último commit, no todo el historial + # 1. NUEVO: Generamos la versión semántica dinámica - name: Definir Versión Semántica id: vars - shell: bash run: | - set -euo pipefail - - VERSION="v1.0.${{ gitea.run_number }}" - echo "VERSION=${VERSION}" >> "${GITHUB_OUTPUT}" - echo "Versión generada: ${VERSION}" + # Formato v1.0.X (X = número de ejecución del pipeline) + echo "VERSION=v1.0.${{ github.run_number }}" >> $GITHUB_OUTPUT - name: Login en Gitea Registry uses: docker/login-action@v2 @@ -36,6 +32,7 @@ jobs: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} + # 2. ACTUALIZADO: Usamos la nueva variable para los tags de Docker - name: Construir y Subir Imagen uses: docker/build-push-action@v4 with: @@ -45,453 +42,25 @@ jobs: gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }} gitea.cruzcloud.net/devops/ecommerce-frontend:latest + # 3. ACTUALIZADO: Inyectamos la versión limpia en el manifiesto YAML - name: Actualizar Manifiesto GitOps (CD) - shell: bash - env: - VERSION: ${{ steps.vars.outputs.VERSION }} - MANIFEST: workloads/ecommerce/frontend.yaml - IMAGE_REPOSITORY: gitea.cruzcloud.net/devops/ecommerce-frontend run: | - set -euo pipefail - git config user.name "Cristian Felipe" git config user.email "cristiancruz0529@gmail.com" - - for intento in 1 2 3; do - echo "--------------------------------------------------" - echo "Intento ${intento}/3" - echo "Versión: ${VERSION}" - echo "Manifiesto: ${MANIFEST}" - echo "--------------------------------------------------" - - git fetch origin main - git checkout -B main origin/main - - if [ ! -f "${MANIFEST}" ]; then - echo "ERROR: no existe ${MANIFEST}." - exit 1 - fi - - if ! grep -qE "image:[[:space:]]*${IMAGE_REPOSITORY}:" "${MANIFEST}"; then - echo "ERROR: no se encontró ${IMAGE_REPOSITORY} en ${MANIFEST}." - grep -nE '^[[:space:]]*image:' "${MANIFEST}" || true - exit 1 - fi - - sed -i -E \ - "s|(image:[[:space:]]*${IMAGE_REPOSITORY}:).*|\\1${VERSION}|g" \ - "${MANIFEST}" - - echo "Imagen resultante:" - grep -nE '^[[:space:]]*image:' "${MANIFEST}" || true - - git add "${MANIFEST}" - - if git diff --cached --quiet; then - echo "El manifiesto ya contiene ${VERSION}." - break - fi - - git commit -m "chore: release ${VERSION} [skip ci]" - - if git push origin HEAD:main; then - echo "Manifiesto actualizado correctamente con ${VERSION}." - break - fi - - if [ "${intento}" -eq 3 ]; then - echo "ERROR: no fue posible actualizar main después de 3 intentos." - exit 1 - fi - - espera=$((intento * 3)) - echo "origin/main cambió. Reintentando en ${espera} segundos..." - sleep "${espera}" - done - - - name: Validar despliegue y tag ejecutado en Kubernetes - shell: bash - env: - VERSION: ${{ steps.vars.outputs.VERSION }} - IMAGE_REPOSITORY: gitea.cruzcloud.net/devops/ecommerce-frontend - NAMESPACE: ecommerce - DEPLOY_TIMEOUT_SECONDS: '420' - KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }} - K8S_SERVER: ${{ secrets.K8S_SERVER }} - K8S_CA_B64: ${{ secrets.K8S_CA_B64 }} - K8S_TOKEN: ${{ secrets.K8S_TOKEN }} - K8S_TLS_SERVER_NAME: ${{ secrets.K8S_TLS_SERVER_NAME }} - run: | - set -euo pipefail - - EXPECTED_IMAGE="${IMAGE_REPOSITORY}:${VERSION}" - KUBECONFIG_PATH="${RUNNER_TEMP:-/tmp}/kubeconfig" - CA_PATH="${RUNNER_TEMP:-/tmp}/kubernetes-ca.crt" - - echo "==================================================" - echo "Validación posterior al despliegue" - echo "Namespace: ${NAMESPACE}" - echo "Imagen esperada: ${EXPECTED_IMAGE}" - echo "Timeout: ${DEPLOY_TIMEOUT_SECONDS} segundos" - echo "==================================================" - - normalize_base64() { - local value="$1" - value="$(printf '%s' "${value}" | tr -d '\r\n\t ')" - - if ! printf '%s' "${value}" | - grep -Eq '^[A-Za-z0-9+/]*={0,2}$'; then - return 1 - fi - - case $(( ${#value} % 4 )) in - 0) ;; - 2) value="${value}==" ;; - 3) value="${value}=" ;; - 1) return 1 ;; - esac - - printf '%s' "${value}" - } - - USE_ATOMIC_KUBECONFIG='false' - KUBE_CONFIG_DATA="$(printf '%s' "${KUBE_CONFIG_DATA:-}" | tr -d '\r\n\t ')" - - if [ -n "${KUBE_CONFIG_DATA}" ]; then - echo 'Usando secret atómico KUBE_CONFIG_DATA.' - - if ! KUBE_CONFIG_DATA="$(normalize_base64 "${KUBE_CONFIG_DATA}")"; then - echo 'ERROR: KUBE_CONFIG_DATA no contiene Base64 válido.' - exit 1 - fi - - if ! printf '%s' "${KUBE_CONFIG_DATA}" | - base64 --decode > "${KUBECONFIG_PATH}" 2>/dev/null; then - echo 'ERROR: no se pudo decodificar KUBE_CONFIG_DATA.' - exit 1 - fi - - if ! grep -q '^kind:[[:space:]]*Config' "${KUBECONFIG_PATH}"; then - echo 'ERROR: KUBE_CONFIG_DATA no contiene un kubeconfig válido.' - exit 1 - fi - - USE_ATOMIC_KUBECONFIG='true' + + # 1. Forzamos a Git a ignorar la historia local y sincronizarse 100% con el servidor + git fetch origin main + git reset --hard origin/main + + # 2. Aplicamos el cambio de versión + sed -i -E "s|(image: gitea.cruzcloud.net/devops/ecommerce-frontend:).*|\1${{ steps.vars.outputs.VERSION }}|g" workloads/ecommerce/frontend.yaml + + # 3. Solo hacemos commit si realmente hubo un cambio + git add workloads/ecommerce/frontend.yaml + if git diff --cached --quiet; then + echo "No hay cambios en el manifiesto." else - echo 'KUBE_CONFIG_DATA no está configurado; usando secrets K8S_* de compatibilidad.' - - for variable in K8S_SERVER K8S_CA_B64 K8S_TOKEN; do - if [ -z "${!variable:-}" ]; then - echo "ERROR: no está configurado el secret ${variable}." - exit 1 - fi - done - - K8S_SERVER="$(printf '%s' "${K8S_SERVER}" | tr -d '\r\n\t ')" - K8S_TOKEN="$(printf '%s' "${K8S_TOKEN}" | tr -d '\r\n')" - K8S_CA_B64="$(printf '%s' "${K8S_CA_B64}" | tr -d '\r\n\t ')" - K8S_TLS_SERVER_NAME="$(printf '%s' "${K8S_TLS_SERVER_NAME:-}" | tr -d '\r\n\t ')" - - if ! printf '%s' "${K8S_SERVER}" | - grep -Eq '^https://[^[:space:]]+:[0-9]+$'; then - echo 'ERROR: K8S_SERVER no tiene un formato válido.' - echo 'Ejemplo: https://192.168.68.61:46421' - exit 1 - fi - - if printf '%s' "${K8S_SERVER}" | - grep -Eq '^https://(127\.0\.0\.1|localhost|0\.0\.0\.0)(:|/)'; then - echo 'ERROR: K8S_SERVER usa una dirección no accesible desde el contenedor.' - exit 1 - fi - - K8S_SERVER_HOST="$( - printf '%s' "${K8S_SERVER}" | - sed -E 's#^https://\[?([^]]+)\]?:[0-9]+$#\1#' - )" - - if [ -z "${K8S_TLS_SERVER_NAME}" ]; then - K8S_TLS_SERVER_NAME="${K8S_SERVER_HOST}" - fi - - if [ "${K8S_TLS_SERVER_NAME}" != "${K8S_SERVER_HOST}" ]; then - echo 'ERROR: K8S_TLS_SERVER_NAME no coincide con el host de K8S_SERVER.' - echo "Host de K8S_SERVER: ${K8S_SERVER_HOST}" - echo "Valor esperado del secret K8S_TLS_SERVER_NAME: ${K8S_SERVER_HOST}" - exit 1 - fi - - if ! K8S_CA_B64="$(normalize_base64 "${K8S_CA_B64}")"; then - echo 'ERROR: K8S_CA_B64 no contiene Base64 válido.' - exit 1 - fi - - if ! printf '%s' "${K8S_CA_B64}" | - base64 --decode > "${CA_PATH}" 2>/dev/null; then - echo 'ERROR: no se pudo decodificar K8S_CA_B64.' - exit 1 - fi - - if ! grep -q 'BEGIN CERTIFICATE' "${CA_PATH}"; then - echo 'ERROR: K8S_CA_B64 no contiene un certificado PEM.' - exit 1 - fi - - chmod 600 "${CA_PATH}" - - { - echo 'apiVersion: v1' - echo 'kind: Config' - echo 'clusters:' - echo '- name: lab-cluster' - echo ' cluster:' - echo " server: ${K8S_SERVER}" - echo " certificate-authority: ${CA_PATH}" - echo " tls-server-name: ${K8S_TLS_SERVER_NAME}" - echo 'users:' - echo '- name: gitea-postdeploy-validator' - echo ' user:' - echo " token: ${K8S_TOKEN}" - echo 'contexts:' - echo '- name: gitea-postdeploy-validator' - echo ' context:' - echo ' cluster: lab-cluster' - echo ' user: gitea-postdeploy-validator' - echo " namespace: ${NAMESPACE}" - echo 'current-context: gitea-postdeploy-validator' - } > "${KUBECONFIG_PATH}" - fi - - chmod 600 "${KUBECONFIG_PATH}" - export KUBECONFIG="${KUBECONFIG_PATH}" - - if ! command -v kubectl >/dev/null 2>&1; then - case "$(uname -m)" in - x86_64) KUBECTL_ARCH='amd64' ;; - aarch64|arm64) KUBECTL_ARCH='arm64' ;; - *) - echo "ERROR: arquitectura no soportada: $(uname -m)" - exit 1 - ;; - esac - - KUBECTL_VERSION="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" - echo "Instalando kubectl ${KUBECTL_VERSION} (${KUBECTL_ARCH})..." - - curl -fsSLo /tmp/kubectl \ - "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl" - curl -fsSLo /tmp/kubectl.sha256 \ - "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl.sha256" - - echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | - sha256sum --check - - chmod +x /tmp/kubectl - export PATH="/tmp:${PATH}" - fi - - kubectl version --client - - EFFECTIVE_SERVER="$( - kubectl config view --raw --minify \ - -o jsonpath='{.clusters[0].cluster.server}' - )" - EFFECTIVE_TLS_NAME="$( - kubectl config view --raw --minify \ - -o jsonpath='{.clusters[0].cluster.tls-server-name}' - )" - EFFECTIVE_CA_B64="$( - kubectl config view --raw --minify \ - -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' - )" - EFFECTIVE_TOKEN="$( - kubectl config view --raw --minify \ - -o jsonpath='{.users[0].user.token}' - )" - - if [ -z "${EFFECTIVE_TOKEN}" ]; then - echo 'ERROR: el kubeconfig no contiene un token de autenticación.' - exit 1 - fi - - echo "API Server efectivo: ${EFFECTIVE_SERVER}" - echo "TLS server name efectivo: ${EFFECTIVE_TLS_NAME:-}" - - if [ -n "${EFFECTIVE_CA_B64}" ]; then - printf '%s' "${EFFECTIVE_CA_B64}" | base64 --decode > "${CA_PATH}" - fi - - if [ ! -s "${CA_PATH}" ] || ! grep -q 'BEGIN CERTIFICATE' "${CA_PATH}"; then - echo 'ERROR: el kubeconfig no contiene una CA PEM válida.' - exit 1 - fi - - if command -v openssl >/dev/null 2>&1; then - echo 'Huella SHA256 de la CA usada por el workflow:' - openssl x509 -in "${CA_PATH}" -noout -fingerprint -sha256 - fi - - TOKEN_METADATA="$( - printf '%s' "${EFFECTIVE_TOKEN}" | - python3 -c ' - import base64 - import datetime - import json - import sys - - token = sys.stdin.read().strip() - parts = token.split(".") - if len(parts) != 3: - print("formato=no-jwt") - raise SystemExit(0) - - payload = parts[1] + "=" * (-len(parts[1]) % 4) - data = json.loads(base64.urlsafe_b64decode(payload.encode())) - - subject = data.get("sub", "desconocido") - expiration = data.get("exp") - - if expiration is None: - print(f"subject={subject}; expiracion=sin-exp") - else: - when = datetime.datetime.fromtimestamp( - expiration, datetime.timezone.utc - ).isoformat() - print(f"subject={subject}; expiracion={when}") - ' - )" - - echo "Identidad del token: ${TOKEN_METADATA}" - - if ! kubectl get deployments \ - --namespace "${NAMESPACE}" \ - --request-timeout='15s' >/dev/null; then - echo "ERROR: no hay conectividad o permisos para leer Deployments en ${NAMESPACE}." - exit 1 - fi - - echo "Conectividad y permisos de lectura confirmados." - - DEADLINE=$((SECONDS + DEPLOY_TIMEOUT_SECONDS)) - DEPLOYMENT='' - - while [ "${SECONDS}" -lt "${DEADLINE}" ]; do - mapfile -t MATCHES < <( - kubectl get deployments \ - --namespace "${NAMESPACE}" \ - --output go-template="{{range .items}}{{\$name := .metadata.name}}{{range .spec.template.spec.containers}}{{if eq .image \"${EXPECTED_IMAGE}\"}}{{\$name}}{{\"\\n\"}}{{end}}{{end}}{{end}}" | - sort -u - ) - - if [ "${#MATCHES[@]}" -eq 1 ] && [ -n "${MATCHES[0]}" ]; then - DEPLOYMENT="${MATCHES[0]}" - echo "Deployment actualizado por Argo CD: ${DEPLOYMENT}" - break - fi - - if [ "${#MATCHES[@]}" -gt 1 ]; then - echo "ERROR: más de un Deployment usa ${EXPECTED_IMAGE}: ${MATCHES[*]}" - exit 1 - fi - - echo "Argo CD aún no ha aplicado ${EXPECTED_IMAGE}. Reintentando en 10 segundos..." - sleep 10 - done - - if [ -z "${DEPLOYMENT}" ]; then - echo "ERROR: Argo CD no aplicó ${EXPECTED_IMAGE} dentro del tiempo límite." - kubectl get deployments \ - --namespace "${NAMESPACE}" \ - --output custom-columns='DEPLOYMENT:.metadata.name,IMAGES:.spec.template.spec.containers[*].image' - exit 1 - fi - - kubectl rollout status "deployment/${DEPLOYMENT}" \ - --namespace "${NAMESPACE}" \ - --timeout="${DEPLOY_TIMEOUT_SECONDS}s" - - CONTAINER_NAME="$( - kubectl get deployment "${DEPLOYMENT}" \ - --namespace "${NAMESPACE}" \ - --output go-template="{{range .spec.template.spec.containers}}{{if eq .image \"${EXPECTED_IMAGE}\"}}{{.name}}{{end}}{{end}}" - )" - - if [ -z "${CONTAINER_NAME}" ]; then - echo "ERROR: no se encontró el contenedor con ${EXPECTED_IMAGE}." - exit 1 - fi - - SELECTOR="$( - kubectl get deployment "${DEPLOYMENT}" \ - --namespace "${NAMESPACE}" \ - --output go-template='{{range $key, $value := .spec.selector.matchLabels}}{{printf "%s=%s," $key $value}}{{end}}' | - sed 's/,$//' - )" - - EXPECTED_REPLICAS="$( - kubectl get deployment "${DEPLOYMENT}" \ - --namespace "${NAMESPACE}" \ - --output jsonpath='{.spec.replicas}' - )" - - mapfile -t PODS < <( - kubectl get pods \ - --namespace "${NAMESPACE}" \ - --selector "${SELECTOR}" \ - --output go-template='{{range .items}}{{if not .metadata.deletionTimestamp}}{{if eq .status.phase "Running"}}{{.metadata.name}}{{"\n"}}{{end}}{{end}}{{end}}' - ) - - if [ "${#PODS[@]}" -ne "${EXPECTED_REPLICAS}" ]; then - echo "ERROR: se esperaban ${EXPECTED_REPLICAS} Pods Running y se encontraron ${#PODS[@]}." - kubectl get pods \ - --namespace "${NAMESPACE}" \ - --selector "${SELECTOR}" \ - -o wide - exit 1 - fi - - VALIDATED_PODS=0 - - for pod in "${PODS[@]}"; do - DECLARED_IMAGE="$( - kubectl get pod "${pod}" \ - --namespace "${NAMESPACE}" \ - --output go-template="{{range .spec.containers}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.image}}{{end}}{{end}}" - )" - - READY="$( - kubectl get pod "${pod}" \ - --namespace "${NAMESPACE}" \ - --output go-template="{{range .status.containerStatuses}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.ready}}{{end}}{{end}}" - )" - - IMAGE_ID="$( - kubectl get pod "${pod}" \ - --namespace "${NAMESPACE}" \ - --output go-template="{{range .status.containerStatuses}}{{if eq .name \"${CONTAINER_NAME}\"}}{{.imageID}}{{end}}{{end}}" - )" - - echo "Pod: ${pod}" - echo " Ready: ${READY}" - echo " Imagen declarada: ${DECLARED_IMAGE}" - echo " Image ID: ${IMAGE_ID}" - - if [ "${DECLARED_IMAGE}" != "${EXPECTED_IMAGE}" ]; then - echo "ERROR: ${pod} ejecuta ${DECLARED_IMAGE}, no ${EXPECTED_IMAGE}." - exit 1 - fi - - if [ "${READY}" != 'true' ]; then - echo "ERROR: el contenedor ${CONTAINER_NAME} de ${pod} no está Ready." - exit 1 - fi - - VALIDATED_PODS=$((VALIDATED_PODS + 1)) - done - - echo "==================================================" - echo "VALIDACIÓN EXITOSA" - echo "Deployment: ${DEPLOYMENT}" - echo "Pods validados: ${VALIDATED_PODS}/${EXPECTED_REPLICAS}" - echo "Tag ejecutado: ${EXPECTED_IMAGE}" - echo "==================================================" \ No newline at end of file + git commit -m "chore: release ${{ steps.vars.outputs.VERSION }} [skip ci]" + # 4. Forzamos el push usando --force para limpiar cualquier discrepancia + git push origin HEAD:main --force + fi \ No newline at end of file diff --git a/.gitea/workflows/catalog-sync.yaml b/.gitea/workflows/catalog-sync.yaml new file mode 100644 index 0000000..0ee951b --- /dev/null +++ b/.gitea/workflows/catalog-sync.yaml @@ -0,0 +1,22 @@ +name: Sync Ari Shopping Catalog +on: + workflow_dispatch: + schedule: + - cron: '@daily' +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: python3 -m pip install --user -r workloads/ecommerce/tools/requirements.txt + - run: python3 workloads/ecommerce/tools/catalog_sync.py --source workloads/ecommerce/catalog-source.csv --download-images + - run: | + git config user.name "Ari Shopping Catalog Bot" + git config user.email "catalog-bot@cruzcloud.net" + git add workloads/ecommerce/data/products.json workloads/ecommerce/assets/img/products + git diff --cached --quiet && exit 0 + git commit -m "chore(catalog): sync products" + git pull --rebase origin main + git push origin HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workloads/ecommerce/.dockerignore b/workloads/ecommerce/.dockerignore new file mode 100644 index 0000000..be1f776 --- /dev/null +++ b/workloads/ecommerce/.dockerignore @@ -0,0 +1,4 @@ +.git +tools/ +catalog-source.csv +README.md diff --git a/workloads/ecommerce/Dockerfile b/workloads/ecommerce/Dockerfile index 6cad475..6b3ea34 100644 --- a/workloads/ecommerce/Dockerfile +++ b/workloads/ecommerce/Dockerfile @@ -1,13 +1,5 @@ -# Usamos una imagen de Nginx basada en Alpine para que sea extremadamente ligera -FROM nginx:alpine - -# Copiamos tus archivos estáticos (o el build de tu frontend) a la carpeta que sirve Nginx -# Asumimos que tus archivos están en una carpeta llamada 'dist' o 'build' tras compilar. -# Si solo son archivos HTML/CSS/JS, puedes copiar el contenido directamente desde '.' +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf COPY . /usr/share/nginx/html - -# Exponemos el puerto 80 por defecto EXPOSE 80 - -# Comando para ejecutar Nginx en modo 'daemon off' para que el contenedor no se detenga -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/healthz || exit 1 diff --git a/workloads/ecommerce/README.md b/workloads/ecommerce/README.md index cc74bc6..e56bd80 100644 --- a/workloads/ecommerce/README.md +++ b/workloads/ecommerce/README.md @@ -1,17 +1,52 @@ -# Ari Shopping Front v1 +# Ari Shopping Frontend v2 -Frontend estático responsive para la primera etapa de la tienda online. +Frontend profesional y dinámico para `apps-registry/workloads/ecommerce/`. ## Incluye -- Home profesional y responsive -- Buscador y filtros por categoría -- Catálogo demostrativo -- Carrito lateral en el navegador -- Integración inicial con Instagram -- Base preparada para consumir APIs de microservicios +- Home responsive. +- Catálogo JSON con búsqueda, categorías y ordenamiento. +- Favoritos y carrito persistentes. +- Pedido por WhatsApp o fallback a Instagram. +- Nginx con `/healthz`. +- CSV -> JSON -> WebP automático. +- Workflow diario opcional para Gitea Actions. -## Despliegue en el laboratorio -Reemplaza `workloads/ecommerce/index.html` con el `index.html` incluido. El pipeline actual construirá la imagen, actualizará el manifiesto GitOps y validará el tag desplegado. +## Configuración +Edita `workloads/ecommerce/data/store.json` y configura: +```json +"whatsappNumber": "57XXXXXXXXXX" +``` -## Antes de producción -Se deben incorporar logo oficial, fotos y precios reales, inventario, número de WhatsApp, políticas comerciales y servicios de catálogo, inventario, carrito, pedidos, pagos y clientes. +## Instalar en apps-registry +```bash +cd ari-shopping-frontend-v2 +APPS_REPO=/DATA/AppData/k3d-lab/git/apps-registry ./tools-install-v2.sh +``` +El instalador conserva `frontend.yaml`, `service.yaml`, `ingress.yaml` y `kustomization.yaml`. + +## Build workflow +El workflow actual debe observar `workloads/ecommerce/**`; revisa `workflow-snippets/build-paths-update.yaml.txt`. + +## Catálogo automático +Edita `catalog-source.csv`. Para imágenes propias o autorizadas, coloca URLs separadas por `|` en `image_urls` y ejecuta: +```bash +cd workloads/ecommerce +python3 -m pip install -r tools/requirements.txt +python3 tools/catalog_sync.py --download-images +``` +Las imágenes se normalizan a 1200x1200 WebP. + +Solo deben usarse imágenes propias, de proveedor o con autorización. + +## Prueba local +```bash +cd workloads/ecommerce +python3 -m http.server 8080 +``` + +## Docker +```bash +docker build -t ari-shopping-frontend:v2 workloads/ecommerce +docker run --rm -p 8080:80 ari-shopping-frontend:v2 +curl http://127.0.0.1:8080/healthz +``` diff --git a/workloads/ecommerce/assets/css/styles.css b/workloads/ecommerce/assets/css/styles.css new file mode 100644 index 0000000..447552c --- /dev/null +++ b/workloads/ecommerce/assets/css/styles.css @@ -0,0 +1,5 @@ + +:root{--ink:#241b2d;--muted:#746a7a;--pink:#ff3f7f;--purple:#7c3aed;--soft:#f8f4f8;--line:#e9e1eb;--shadow:0 20px 55px rgba(47,28,55,.12)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;color:var(--ink);font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}body.lock{overflow:hidden}a{text-decoration:none;color:inherit}button,input,select{font:inherit}.shell{width:min(calc(100% - 32px),1240px);margin:auto}.announcement{padding:9px;text-align:center;color:#fff;background:linear-gradient(90deg,var(--pink),var(--purple));font-size:13px}header{position:sticky;top:0;z-index:20;background:#ffffffed;border-bottom:1px solid var(--line);backdrop-filter:blur(16px)}.head{min-height:84px;display:grid;grid-template-columns:190px 1fr auto auto;gap:18px;align-items:center}.head img{width:175px}.search{display:flex;align-items:center;gap:9px;padding:0 16px;min-height:48px;border:1px solid var(--line);border-radius:999px;background:var(--soft)}.search input{width:100%;border:0;outline:0;background:transparent}.ig,.cart{padding:12px 16px;border:1px solid var(--line);border-radius:999px;background:#fff}.cart{cursor:pointer}.cart b{display:inline-grid;place-items:center;min-width:23px;height:23px;margin-left:6px;border-radius:50%;color:#fff;background:var(--ink)}.hero{min-height:600px;display:grid;grid-template-columns:1.05fr .95fr;gap:48px;align-items:center;padding:70px 0}.eyebrow{color:#d91f60;font-size:12px;font-weight:850;letter-spacing:.12em;text-transform:uppercase}.hero h1{margin:15px 0 20px;font-size:clamp(48px,6.4vw,82px);line-height:.98;letter-spacing:-.055em}.hero p{max-width:650px;color:var(--muted);font-size:18px;line-height:1.7}.heroActions{display:flex;gap:12px;flex-wrap:wrap;margin-top:28px}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:47px;padding:0 20px;border:0;border-radius:999px;font-weight:800;cursor:pointer}.primary{color:#fff;background:linear-gradient(90deg,var(--pink),var(--purple));box-shadow:0 12px 27px #ff3f7f38}.ghost{background:var(--soft);border:1px solid var(--line)}.light{background:#fff}.art{min-height:480px;display:grid;place-items:center;border-radius:42px;background:radial-gradient(circle at 75% 18%,#fff,transparent 25%),linear-gradient(135deg,#ffd8e7,#e8ddff 55%,#cef6ed);box-shadow:var(--shadow)}.art>div{width:70%;padding:38px;border:1px solid #ffffffb0;border-radius:32px;background:#ffffffb5;transform:rotate(-6deg);box-shadow:var(--shadow);backdrop-filter:blur(12px)}.art small,.art span{display:block;color:var(--muted)}.art strong{display:block;margin:12px 0 28px;font-size:42px;line-height:1.02}.benefits{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:100px}.benefits article{display:flex;gap:14px;padding:24px;border:1px solid var(--line);border-radius:22px}.benefits b{width:38px;height:38px;display:grid;place-items:center;border-radius:50%;color:#fff;background:var(--ink)}.benefits span strong,.benefits span small{display:block}.benefits span small{margin-top:5px;color:var(--muted)}.catalog{padding-bottom:110px}.sectionTitle{display:flex;justify-content:space-between;align-items:end;gap:24px}.sectionTitle h2,.instagram h2{margin:8px 0;font-size:clamp(34px,4vw,52px);letter-spacing:-.04em}.sectionTitle p{color:var(--muted)}.sectionTitle select{min-height:46px;padding:0 14px;border:1px solid var(--line);border-radius:12px;background:#fff}.categories{display:flex;gap:9px;overflow:auto;padding:18px 0 24px}.chip{flex:0 0 auto;padding:10px 16px;border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;font-weight:750}.chip.active{color:#fff;background:var(--ink)}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.card{overflow:hidden;border:1px solid var(--line);border-radius:22px;background:#fff;transition:.25s}.card:hover{transform:translateY(-5px);box-shadow:var(--shadow)}.media{position:relative;aspect-ratio:1;background:var(--soft);overflow:hidden}.media img{width:100%;height:100%;object-fit:cover}.badges{position:absolute;z-index:2;top:12px;left:12px;display:flex;gap:5px}.badge{padding:6px 9px;border-radius:999px;color:#fff;background:var(--purple);font-size:10px;font-weight:850}.badge.sale{background:var(--pink)}.wish{position:absolute;z-index:2;right:12px;top:12px;width:40px;height:40px;border:1px solid #fff;border-radius:50%;background:#ffffffd9;font-size:23px;cursor:pointer}.wish.active{color:var(--pink)}.body{padding:18px}.body>.category{color:var(--muted);font-weight:700}.body h3{margin:8px 0;font-size:17px}.body p{min-height:42px;color:var(--muted);font-size:13px;line-height:1.55}.price{display:flex;align-items:baseline;gap:8px}.price strong{font-size:20px}.price del{color:#9e929f;font-size:13px}.body .add{width:100%;margin-top:15px}.empty{padding:60px;text-align:center;border:1px dashed var(--line);border-radius:22px;background:var(--soft)}.instagram{padding:80px 0;color:#fff;background:linear-gradient(110deg,#24152b,#6d1a55,#4a2b85)}.instagram>.shell{display:flex;align-items:center;justify-content:space-between;gap:30px}.instagram .eyebrow{color:#ffafca}footer{padding:50px 0 25px;color:#c9bdcd;background:#1c1521}.foot{display:flex;align-items:center;justify-content:space-between;gap:25px}.foot img{width:170px;border-radius:15px}.overlay{position:fixed;z-index:50;inset:0;background:#1c152199;backdrop-filter:blur(3px)}.drawer{position:fixed;z-index:60;top:0;right:0;width:min(430px,100%);height:100dvh;display:grid;grid-template-rows:auto 1fr auto;background:#fff;transform:translateX(105%);transition:.25s;box-shadow:-30px 0 80px #1c152144}.drawer.open{transform:none}.drawerHead,.drawerFoot{padding:22px;border-bottom:1px solid var(--line)}.drawerHead{display:flex;justify-content:space-between;align-items:center}.drawerHead h2{margin:4px 0}.drawerHead button{width:42px;height:42px;border:1px solid var(--line);border-radius:50%;background:#fff;font-size:25px}.drawerBody{overflow:auto;padding:14px 22px}.drawerFoot{border-top:1px solid var(--line);border-bottom:0}.drawerFoot>div{display:flex;justify-content:space-between;margin-bottom:14px}.drawerFoot>div strong{font-size:24px}.drawerFoot small{display:block;margin-top:12px;color:var(--muted);text-align:center}.full{width:100%}.link{width:100%;padding:12px;border:0;background:transparent;color:#d91f60;cursor:pointer}.line{display:grid;grid-template-columns:74px 1fr auto;gap:11px;padding:13px 0;border-bottom:1px solid var(--line)}.line img{width:74px;height:74px;border-radius:13px}.line small{display:block;color:var(--muted);margin:5px 0}.qty{display:flex;align-items:center;gap:8px}.qty button{border:1px solid var(--line);border-radius:50%;background:#fff}.remove{border:0;background:transparent;font-size:20px}.drawerEmpty{padding:60px 10px;text-align:center;color:var(--muted)}.toast{position:fixed;z-index:90;right:18px;bottom:18px;padding:14px 18px;border-radius:14px;color:#fff;background:var(--ink);box-shadow:var(--shadow)} +@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.hero{grid-template-columns:1fr 1fr}.head{grid-template-columns:160px 1fr auto}.ig{display:none}.benefits{grid-template-columns:1fr}} +@media(max-width:760px){.head{grid-template-columns:1fr auto;padding:10px 0}.head img{width:150px}.search{grid-column:1/-1;grid-row:2}.hero{grid-template-columns:1fr;padding:50px 0}.art{min-height:360px}.grid{grid-template-columns:repeat(2,1fr)}.sectionTitle{align-items:stretch;flex-direction:column}.instagram>.shell,.foot{align-items:flex-start;flex-direction:column}} +@media(max-width:480px){.shell{width:min(calc(100% - 22px),1240px)}.hero h1{font-size:48px}.art>div{width:80%;padding:26px}.art strong{font-size:34px}.grid{gap:10px}.body{padding:13px}.body h3{font-size:14px}.body p{display:none}.price strong{font-size:16px}.foot img{width:150px}} diff --git a/workloads/ecommerce/assets/img/brand/favicon.svg b/workloads/ecommerce/assets/img/brand/favicon.svg new file mode 100644 index 0000000..415cc3c --- /dev/null +++ b/workloads/ecommerce/assets/img/brand/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/brand/logo.svg b/workloads/ecommerce/assets/img/brand/logo.svg new file mode 100644 index 0000000..ca5e95e --- /dev/null +++ b/workloads/ecommerce/assets/img/brand/logo.svg @@ -0,0 +1 @@ +AriShopping \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-001.svg b/workloads/ecommerce/assets/img/products/ari-001.svg new file mode 100644 index 0000000..aefe3d2 --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-001.svg @@ -0,0 +1 @@ + Bolso Crossbody Aura \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-002.svg b/workloads/ecommerce/assets/img/products/ari-002.svg new file mode 100644 index 0000000..a2c2a2b --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-002.svg @@ -0,0 +1 @@ + Set Glow Skincare \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-003.svg b/workloads/ecommerce/assets/img/products/ari-003.svg new file mode 100644 index 0000000..296c77c --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-003.svg @@ -0,0 +1 @@ + Reloj Minimal Rose \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-004.svg b/workloads/ecommerce/assets/img/products/ari-004.svg new file mode 100644 index 0000000..1ddbb10 --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-004.svg @@ -0,0 +1 @@ + Audífonos Mini Pods \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-005.svg b/workloads/ecommerce/assets/img/products/ari-005.svg new file mode 100644 index 0000000..76cfb70 --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-005.svg @@ -0,0 +1 @@ + Organizador Vanity \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-006.svg b/workloads/ecommerce/assets/img/products/ari-006.svg new file mode 100644 index 0000000..6e9b344 --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-006.svg @@ -0,0 +1 @@ + Kit Brochas Essential \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-007.svg b/workloads/ecommerce/assets/img/products/ari-007.svg new file mode 100644 index 0000000..1463bd9 --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-007.svg @@ -0,0 +1 @@ + Termo Trend 1.2L \ No newline at end of file diff --git a/workloads/ecommerce/assets/img/products/ari-008.svg b/workloads/ecommerce/assets/img/products/ari-008.svg new file mode 100644 index 0000000..6d7de5b --- /dev/null +++ b/workloads/ecommerce/assets/img/products/ari-008.svg @@ -0,0 +1 @@ + Smart Band Active \ No newline at end of file diff --git a/workloads/ecommerce/assets/js/app.js b/workloads/ecommerce/assets/js/app.js new file mode 100644 index 0000000..e33db6b --- /dev/null +++ b/workloads/ecommerce/assets/js/app.js @@ -0,0 +1 @@ +(()=>{"use strict";const $=(s,r=document)=>r.querySelector(s),$$=(s,r=document)=>[...r.querySelectorAll(s)];const S={products:[],store:null,category:"Todos",query:"",sort:"featured",cart:JSON.parse(localStorage.getItem("ari-cart-v2")||"{}"),wish:JSON.parse(localStorage.getItem("ari-wish-v2")||"[]")};const E={grid:$("#grid"),cat:$("#categories"),search:$("#search"),sort:$("#sort"),summary:$("#summary"),empty:$("#empty"),drawer:$("#drawer"),overlay:$("#overlay"),items:$("#cartItems"),count:$("#cartCount"),total:$("#cartTotal"),toast:$("#toast"),card:$("#card")};const money=v=>new Intl.NumberFormat(S.store?.locale||"es-CO",{style:"currency",currency:S.store?.currency||"COP",maximumFractionDigits:0}).format(v);const norm=v=>String(v||"").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"");const p=id=>S.products.find(x=>x.id===id);function save(){localStorage.setItem("ari-cart-v2",JSON.stringify(S.cart));localStorage.setItem("ari-wish-v2",JSON.stringify(S.wish))}function toast(m){E.toast.textContent=m;E.toast.hidden=false;setTimeout(()=>E.toast.hidden=true,2600)}function categories(){E.cat.innerHTML=["Todos",...new Set(S.products.map(x=>x.category))].map(x=>``).join("")}function list(){let q=norm(S.query),a=S.products.filter(x=>x.available!==false&&(S.category==="Todos"||x.category===S.category)&&(!q||norm([x.name,x.category,x.description,...(x.tags||[])].join(" ")).includes(q)));a.sort((x,y)=>S.sort==="asc"?x.price-y.price:S.sort==="desc"?y.price-x.price:S.sort==="new"?Number(y.new)-Number(x.new):Number(y.featured)-Number(x.featured));return a}function render(){let a=list();E.grid.innerHTML="";E.summary.textContent=`${a.length} producto${a.length===1?"":"s"} disponible${a.length===1?"":"s"}`;E.empty.hidden=!!a.length;a.forEach(x=>{let f=E.card.content.cloneNode(true),c=$(".card",f),img=$("img",f),w=$(".wish",f);img.src=x.images?.[0]||"assets/img/brand/favicon.svg";img.alt=x.name;$(".category",f).textContent=x.category;$("h3",f).textContent=x.name;$(".price strong",f).textContent=money(x.price);let d=$(".price del",f);x.compareAtPrice>x.price?d.textContent=money(x.compareAtPrice):d.remove();$(".body p",f).textContent=x.description;if(x.new)$(".badges",f).insertAdjacentHTML("beforeend",'Nuevo');if(x.compareAtPrice>x.price)$(".badges",f).insertAdjacentHTML("beforeend",`-${Math.round((1-x.price/x.compareAtPrice)*100)}%`);w.classList.toggle("active",S.wish.includes(x.id));w.textContent=S.wish.includes(x.id)?"♥":"♡";w.onclick=()=>{S.wish=S.wish.includes(x.id)?S.wish.filter(i=>i!==x.id):[...S.wish,x.id];save();render();toast(S.wish.includes(x.id)?"Guardado en favoritos":"Eliminado de favoritos")};$(".add",f).onclick=()=>add(x.id);E.grid.append(f)});cart()}function add(id){S.cart[id]=(S.cart[id]||0)+1;save();cart();toast(`${p(id).name} agregado`)}function cart(){let a=Object.entries(S.cart).map(([id,q])=>({x:p(id),q})).filter(i=>i.x),n=a.reduce((s,i)=>s+i.q,0),t=a.reduce((s,i)=>s+i.x.price*i.q,0);E.count.textContent=n;E.total.textContent=money(t);E.items.innerHTML=a.length?a.map(i=>`
${i.x.name}${money(i.x.price)}
${i.q}
`).join(""):'
Tu carrito está vacío.
';$$('[data-d]',E.items).forEach(b=>b.onclick=()=>{let id=b.closest('[data-id]').dataset.id;S.cart[id]+=Number(b.dataset.d);if(S.cart[id]<=0)delete S.cart[id];save();cart()});$$('.remove',E.items).forEach(b=>b.onclick=()=>{delete S.cart[b.closest('[data-id]').dataset.id];save();cart()})}function open(){E.drawer.classList.add("open");E.overlay.hidden=false;document.body.classList.add("lock")}function close(){E.drawer.classList.remove("open");E.overlay.hidden=true;document.body.classList.remove("lock")}function order(){let a=Object.entries(S.cart).map(([id,q])=>({x:p(id),q})).filter(i=>i.x);if(!a.length)return;let total=a.reduce((s,i)=>s+i.x.price*i.q,0),m=[`Hola ${S.store.brand} 👋`,`Quiero confirmar disponibilidad:`,"",...a.map(i=>`• ${i.q} x ${i.x.name} — ${money(i.x.price*i.q)}`),"",`Total estimado: ${money(total)}`,"","Nombre:","Ciudad:"].join("\n"),num=String(S.store.whatsappNumber||"").replace(/\D/g,"");if(num)window.open(`https://wa.me/${num}?text=${encodeURIComponent(m)}`,"_blank");else navigator.clipboard.writeText(m).then(()=>toast("Pedido copiado. Configura WhatsApp en data/store.json"))}async function init(){let [st,pr]=await Promise.all([fetch("data/store.json",{cache:"no-store"}).then(r=>r.json()),fetch("data/products.json",{cache:"no-store"}).then(r=>r.json())]);S.store=st;S.products=pr.products||[];categories();render()}E.search.oninput=e=>{S.query=e.target.value;render()};E.sort.onchange=e=>{S.sort=e.target.value;render()};E.cat.onclick=e=>{let b=e.target.closest('[data-c]');if(!b)return;S.category=b.dataset.c;categories();render()};$("#cartOpen").onclick=open;$("#cartClose").onclick=close;E.overlay.onclick=close;$("#clearCart").onclick=()=>{S.cart={};save();cart()};$("#checkout").onclick=order;init().catch(e=>{console.error(e);E.summary.textContent="Error cargando catálogo"})})(); \ No newline at end of file diff --git a/workloads/ecommerce/catalog-source.csv b/workloads/ecommerce/catalog-source.csv new file mode 100644 index 0000000..6439d46 --- /dev/null +++ b/workloads/ecommerce/catalog-source.csv @@ -0,0 +1,9 @@ +id,name,category,price,compare_at_price,description,tags,featured,new,available,stock,image_urls,source_name,source_url +ari-001,Bolso Crossbody Aura,Accesorios,89900,109900,Bolso compacto de uso diario con correa ajustable.,accesorios|bolso,true,true,true,9,,Catálogo inicial Ari Shopping, +ari-002,Set Glow Skincare,Belleza,74900,89900,Rutina esencial de cuidado facial para una apariencia luminosa.,belleza|set,true,false,true,10,,Catálogo inicial Ari Shopping, +ari-003,Reloj Minimal Rose,Accesorios,119900,149900,Diseño minimalista con correa suave y detalle metálico.,accesorios|reloj,false,false,true,11,,Catálogo inicial Ari Shopping, +ari-004,Audífonos Mini Pods,Tecnología,99900,129900,Audífonos inalámbricos con estuche de carga portátil.,tecnología|audífonos,true,true,true,12,,Catálogo inicial Ari Shopping, +ari-005,Organizador Vanity,Hogar,64900,79900,"Organizador para maquillaje, accesorios y artículos personales.",hogar|organizador,false,false,true,13,,Catálogo inicial Ari Shopping, +ari-006,Kit Brochas Essential,Belleza,59900,74900,Brochas suaves para una rutina de maquillaje completa.,belleza|kit,false,true,true,14,,Catálogo inicial Ari Shopping, +ari-007,Termo Trend 1.2L,Hogar,84900,99900,"Termo de gran capacidad con tapa, pitillo y asa.",hogar|termo,true,false,true,15,,Catálogo inicial Ari Shopping, +ari-008,Smart Band Active,Tecnología,109900,139900,Pulsera inteligente para actividad diaria y notificaciones.,tecnología|smart,true,false,true,16,,Catálogo inicial Ari Shopping, diff --git a/workloads/ecommerce/data/products.json b/workloads/ecommerce/data/products.json new file mode 100644 index 0000000..b49ae2c --- /dev/null +++ b/workloads/ecommerce/data/products.json @@ -0,0 +1,191 @@ +{ + "version": "2.0.0", + "updatedAt": "2026-07-18T00:00:00-05:00", + "currency": "COP", + "products": [ + { + "id": "ari-001", + "name": "Bolso Crossbody Aura", + "category": "Accesorios", + "price": 89900, + "compareAtPrice": 109900, + "featured": true, + "new": true, + "description": "Bolso compacto de uso diario con correa ajustable.", + "tags": [ + "accesorios", + "bolso" + ], + "images": [ + "assets/img/products/ari-001.svg" + ], + "stock": 9, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-002", + "name": "Set Glow Skincare", + "category": "Belleza", + "price": 74900, + "compareAtPrice": 89900, + "featured": true, + "new": false, + "description": "Rutina esencial de cuidado facial para una apariencia luminosa.", + "tags": [ + "belleza", + "set" + ], + "images": [ + "assets/img/products/ari-002.svg" + ], + "stock": 10, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-003", + "name": "Reloj Minimal Rose", + "category": "Accesorios", + "price": 119900, + "compareAtPrice": 149900, + "featured": false, + "new": false, + "description": "Diseño minimalista con correa suave y detalle metálico.", + "tags": [ + "accesorios", + "reloj" + ], + "images": [ + "assets/img/products/ari-003.svg" + ], + "stock": 11, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-004", + "name": "Audífonos Mini Pods", + "category": "Tecnología", + "price": 99900, + "compareAtPrice": 129900, + "featured": true, + "new": true, + "description": "Audífonos inalámbricos con estuche de carga portátil.", + "tags": [ + "tecnología", + "audífonos" + ], + "images": [ + "assets/img/products/ari-004.svg" + ], + "stock": 12, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-005", + "name": "Organizador Vanity", + "category": "Hogar", + "price": 64900, + "compareAtPrice": 79900, + "featured": false, + "new": false, + "description": "Organizador para maquillaje, accesorios y artículos personales.", + "tags": [ + "hogar", + "organizador" + ], + "images": [ + "assets/img/products/ari-005.svg" + ], + "stock": 13, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-006", + "name": "Kit Brochas Essential", + "category": "Belleza", + "price": 59900, + "compareAtPrice": 74900, + "featured": false, + "new": true, + "description": "Brochas suaves para una rutina de maquillaje completa.", + "tags": [ + "belleza", + "kit" + ], + "images": [ + "assets/img/products/ari-006.svg" + ], + "stock": 14, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-007", + "name": "Termo Trend 1.2L", + "category": "Hogar", + "price": 84900, + "compareAtPrice": 99900, + "featured": true, + "new": false, + "description": "Termo de gran capacidad con tapa, pitillo y asa.", + "tags": [ + "hogar", + "termo" + ], + "images": [ + "assets/img/products/ari-007.svg" + ], + "stock": 15, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + }, + { + "id": "ari-008", + "name": "Smart Band Active", + "category": "Tecnología", + "price": 109900, + "compareAtPrice": 139900, + "featured": true, + "new": false, + "description": "Pulsera inteligente para actividad diaria y notificaciones.", + "tags": [ + "tecnología", + "smart" + ], + "images": [ + "assets/img/products/ari-008.svg" + ], + "stock": 16, + "available": true, + "source": { + "type": "demo", + "name": "Catálogo inicial Ari Shopping" + } + } + ] +} diff --git a/workloads/ecommerce/data/store.json b/workloads/ecommerce/data/store.json new file mode 100644 index 0000000..e15b8b2 --- /dev/null +++ b/workloads/ecommerce/data/store.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "brand": "Ari Shopping", + "tagline": "Detalles que enamoran, compras que disfrutas.", + "instagramUrl": "https://www.instagram.com/ari_shopping1/", + "whatsappNumber": "", + "locale": "es-CO", + "currency": "COP", + "catalogUrl": "data/products.json" +} diff --git a/workloads/ecommerce/index.html b/workloads/ecommerce/index.html index 70a41fc..79b2900 100644 --- a/workloads/ecommerce/index.html +++ b/workloads/ecommerce/index.html @@ -1,87 +1,11 @@ - - - - - - - - Ari Shopping | Tu tienda online - - - -
Envíos Seguros · Pagos fáciles · Atención personalizada
-
- - -
- -
-
-
Nueva colección

Encuentra ese detalle que hace el día más especial.

Juguetes, minifiguras, bolsos y regalos seleccionados para sorprender. Compra fácil y recibe atención personalizada.

-
Juguetes coloridos
Compra con acompañamientoTe ayudamos a elegir, confirmar disponibilidad y coordinar tu envío.
-
- -
🚚
Envíos segurosCoordinación y seguimiento.
💳
Pagos fácilesOpciones claras para comprar.
💗
Atención cercanaAntes y después de tu compra.
- -

Compra por categoría

Encuentra rápido el regalo, juguete o accesorio ideal.

Ver todo →
-
Juguetes
JuguetesDiversión para todas las edades
-
Minifiguras
MinifigurasPersonajes para coleccionar
-
Mochilas
Bolsos y mochilasEstilo para cada aventura
-
Regalos
RegalosDetalles que emocionan
-
- -

Productos destacados

Catálogo demostrativo listo para conectarse después con el microservicio de productos.

Consultar disponibilidad →
- -
@ari_shopping1

Descubre novedades todos los días.

Sigue la cuenta para conocer productos recién llegados, promociones y disponibilidad. La tienda online transformará esa comunidad en una experiencia de compra completa.

Seguir en Instagram
ProductoJuguetesMochilaRegaloJuguetesRegalos
-
- - - -
- - - - +Ari Shopping +
Envíos nacionales · Atención personalizada · Precios en COP
+
Ari ShoppingInstagram
+
Ari Shopping · Colección destacada

Detalles que hacen tu día más especial.

Una experiencia de compra moderna, cercana y rápida. Descubre novedades, favoritos y productos seleccionados para ti.

Nuevos favoritosEstilo para cada momentoDesde $59.900
+
01Compra simpleAgrega y envía tu pedido.
02Catálogo dinámicoProductos desde una fuente central.
03Atención directaConfirma disponibilidad antes de pagar.
+ +
Comunidad Ari Shopping

Novedades y promociones en Instagram.

@ari_shopping1
+
Ari Shopping

Detalles que enamoran, compras que disfrutas.

Frontend v2.0.0
+ + + \ No newline at end of file diff --git a/workloads/ecommerce/nginx.conf b/workloads/ecommerce/nginx.conf new file mode 100644 index 0000000..877ffb6 --- /dev/null +++ b/workloads/ecommerce/nginx.conf @@ -0,0 +1 @@ +server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; charset utf-8; server_tokens off; location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; } location ~* \.(css|js|svg|png|jpg|jpeg|webp|ico)$ { expires 7d; add_header Cache-Control "public, max-age=604800"; try_files $uri =404; } location ~* \.json$ { expires -1; add_header Cache-Control "no-store"; try_files $uri =404; } location / { try_files $uri $uri/ /index.html; } add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; } \ No newline at end of file diff --git a/workloads/ecommerce/tools/catalog_sync.py b/workloads/ecommerce/tools/catalog_sync.py new file mode 100644 index 0000000..ae24094 --- /dev/null +++ b/workloads/ecommerce/tools/catalog_sync.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import argparse,csv,hashlib,io,json,re,sys,urllib.request +from datetime import datetime,timezone +from pathlib import Path +try: + from PIL import Image,ImageOps +except ImportError: + Image=ImageOps=None +ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'data/products.json';IMG=ROOT/'assets/img/products' +def b(v,d=False): return str(v or '').strip().lower() in {'1','true','yes','si','sí'} if str(v or '').strip() else d +def n(v,d=0): + try:return int(float(str(v or '').strip())) + except:return d +def slug(v):return re.sub(r'[^a-zA-Z0-9_-]+','-',v.strip()).strip('-').lower() or hashlib.sha256(v.encode()).hexdigest()[:12] +def download(url): + req=urllib.request.Request(url,headers={'User-Agent':'AriShoppingCatalogSync/2.0'});data=urllib.request.urlopen(req,timeout=25).read(12*1024*1024+1) + if len(data)>12*1024*1024:raise ValueError('Imagen demasiado grande') + return data +def image(data,dst): + if Image is None:raise RuntimeError('Instala Pillow') + im=ImageOps.exif_transpose(Image.open(io.BytesIO(data))).convert('RGB');canvas=Image.new('RGB',(1200,1200),'white');im.thumbnail((1080,1080),Image.Resampling.LANCZOS);canvas.paste(im,((1200-im.width)//2,(1200-im.height)//2));canvas.save(dst,'WEBP',quality=86,method=6) +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--source',type=Path,default=ROOT/'catalog-source.csv');ap.add_argument('--download-images',action='store_true');a=ap.parse_args();items=[] + with a.source.open(encoding='utf-8-sig') as f: + for row in csv.DictReader(f): + pid=slug(row.get('id') or row['name']);imgs=[];urls=[u.strip() for u in row.get('image_urls','').split('|') if u.strip()] + if a.download_images: + for i,u in enumerate(urls,1): + try:dst=IMG/f'{pid}-{i}.webp';image(download(u),dst);imgs.append(str(dst.relative_to(ROOT)).replace('\\','/')) + except Exception as e:print(f'AVISO {pid}: {e}',file=sys.stderr) + if not imgs: + local=IMG/f'{pid}.svg';imgs=[str(local.relative_to(ROOT)).replace('\\','/')] if local.exists() else urls + items.append({'id':pid,'name':row['name'].strip(),'category':row.get('category','Otros').strip(),'price':n(row.get('price')),'compareAtPrice':n(row.get('compare_at_price')),'featured':b(row.get('featured')),'new':b(row.get('new')),'description':row.get('description','').strip(),'tags':[t.strip() for t in row.get('tags','').split('|') if t.strip()],'images':imgs,'stock':n(row.get('stock')),'available':b(row.get('available'),True),'source':{'type':'authorized-feed','name':row.get('source_name','Fuente autorizada'),'url':row.get('source_url','')}}) + OUT.write_text(json.dumps({'version':'2.0.0','updatedAt':datetime.now(timezone.utc).isoformat(),'currency':'COP','products':items},ensure_ascii=False,indent=2)+'\n');print(f'OK: {len(items)} productos') +if __name__=='__main__':raise SystemExit(main()) diff --git a/workloads/ecommerce/tools/requirements.txt b/workloads/ecommerce/tools/requirements.txt new file mode 100644 index 0000000..9673b41 --- /dev/null +++ b/workloads/ecommerce/tools/requirements.txt @@ -0,0 +1 @@ +Pillow==11.3.0