feat(ecommerce): release Ari Shopping Frontend v2
Build and Push Frontend / build (push) Successful in 1m57s
@@ -1,33 +1,29 @@
|
|||||||
name: Build, Push and Validate Frontend
|
name: Build and Push Frontend
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'workloads/ecommerce/index.html'
|
- 'workloads/ecommerce/**'
|
||||||
- 'workloads/ecommerce/Dockerfile'
|
|
||||||
- '.gitea/workflows/build.yaml'
|
- '.gitea/workflows/build.yaml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20 # Aumentamos a 20 min para evitar que se corte el clone
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
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
|
- name: Definir Versión Semántica
|
||||||
id: vars
|
id: vars
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
# Formato v1.0.X (X = número de ejecución del pipeline)
|
||||||
|
echo "VERSION=v1.0.${{ github.run_number }}" >> $GITHUB_OUTPUT
|
||||||
VERSION="v1.0.${{ gitea.run_number }}"
|
|
||||||
echo "VERSION=${VERSION}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "Versión generada: ${VERSION}"
|
|
||||||
|
|
||||||
- name: Login en Gitea Registry
|
- name: Login en Gitea Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -36,6 +32,7 @@ jobs:
|
|||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
# 2. ACTUALIZADO: Usamos la nueva variable para los tags de Docker
|
||||||
- name: Construir y Subir Imagen
|
- name: Construir y Subir Imagen
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -45,453 +42,25 @@ jobs:
|
|||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
||||||
|
|
||||||
|
# 3. ACTUALIZADO: Inyectamos la versión limpia en el manifiesto YAML
|
||||||
- name: Actualizar Manifiesto GitOps (CD)
|
- 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: |
|
run: |
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
git config user.name "Cristian Felipe"
|
git config user.name "Cristian Felipe"
|
||||||
git config user.email "[email protected]"
|
git config user.email "[email protected]"
|
||||||
|
|
||||||
for intento in 1 2 3; do
|
# 1. Forzamos a Git a ignorar la historia local y sincronizarse 100% con el servidor
|
||||||
echo "--------------------------------------------------"
|
git fetch origin main
|
||||||
echo "Intento ${intento}/3"
|
git reset --hard origin/main
|
||||||
echo "Versión: ${VERSION}"
|
|
||||||
echo "Manifiesto: ${MANIFEST}"
|
|
||||||
echo "--------------------------------------------------"
|
|
||||||
|
|
||||||
git fetch origin main
|
# 2. Aplicamos el cambio de versión
|
||||||
git checkout -B main origin/main
|
sed -i -E "s|(image: gitea.cruzcloud.net/devops/ecommerce-frontend:).*|\1${{ steps.vars.outputs.VERSION }}|g" workloads/ecommerce/frontend.yaml
|
||||||
|
|
||||||
if [ ! -f "${MANIFEST}" ]; then
|
# 3. Solo hacemos commit si realmente hubo un cambio
|
||||||
echo "ERROR: no existe ${MANIFEST}."
|
git add workloads/ecommerce/frontend.yaml
|
||||||
exit 1
|
if git diff --cached --quiet; then
|
||||||
fi
|
echo "No hay cambios en el manifiesto."
|
||||||
|
|
||||||
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'
|
|
||||||
else
|
else
|
||||||
echo 'KUBE_CONFIG_DATA no está configurado; usando secrets K8S_* de compatibilidad.'
|
git commit -m "chore: release ${{ steps.vars.outputs.VERSION }} [skip ci]"
|
||||||
|
# 4. Forzamos el push usando --force para limpiar cualquier discrepancia
|
||||||
for variable in K8S_SERVER K8S_CA_B64 K8S_TOKEN; do
|
git push origin HEAD:main --force
|
||||||
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
|
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:-<host del API Server>}"
|
|
||||||
|
|
||||||
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 "=================================================="
|
|
||||||
@@ -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 "[email protected]"
|
||||||
|
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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
tools/
|
||||||
|
catalog-source.csv
|
||||||
|
README.md
|
||||||
@@ -1,13 +1,5 @@
|
|||||||
# Usamos una imagen de Nginx basada en Alpine para que sea extremadamente ligera
|
FROM nginx:1.27-alpine
|
||||||
FROM nginx:alpine
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 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 '.'
|
|
||||||
COPY . /usr/share/nginx/html
|
COPY . /usr/share/nginx/html
|
||||||
|
|
||||||
# Exponemos el puerto 80 por defecto
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
||||||
# Comando para ejecutar Nginx en modo 'daemon off' para que el contenedor no se detenga
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|||||||
@@ -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
|
## Incluye
|
||||||
- Home profesional y responsive
|
- Home responsive.
|
||||||
- Buscador y filtros por categoría
|
- Catálogo JSON con búsqueda, categorías y ordenamiento.
|
||||||
- Catálogo demostrativo
|
- Favoritos y carrito persistentes.
|
||||||
- Carrito lateral en el navegador
|
- Pedido por WhatsApp o fallback a Instagram.
|
||||||
- Integración inicial con Instagram
|
- Nginx con `/healthz`.
|
||||||
- Base preparada para consumir APIs de microservicios
|
- CSV -> JSON -> WebP automático.
|
||||||
|
- Workflow diario opcional para Gitea Actions.
|
||||||
|
|
||||||
## Despliegue en el laboratorio
|
## Configuración
|
||||||
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.
|
Edita `workloads/ecommerce/data/store.json` y configura:
|
||||||
|
```json
|
||||||
|
"whatsappNumber": "57XXXXXXXXXX"
|
||||||
|
```
|
||||||
|
|
||||||
## Antes de producción
|
## Instalar en apps-registry
|
||||||
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.
|
```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
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g"><stop stop-color="#ff3f7f"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs><rect width="64" height="64" rx="18" fill="url(#g)"/><path d="M15 48 28 16h8l14 32H39l-3-8H27l-3 8Zm15-17h4l-2-6Z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 314 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 100"><defs><linearGradient id="g"><stop stop-color="#ff3f7f"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs><rect width="360" height="100" rx="24" fill="#fff"/><circle cx="50" cy="50" r="34" fill="url(#g)"/><path d="M31 67 47 30h8l17 37H59l-3-8H44l-3 8Zm17-18h5l-2.5-7Z" fill="#fff"/><text x="95" y="48" font-family="Arial" font-size="30" font-weight="800" fill="#201a2b">Ari</text><text x="95" y="76" font-family="Arial" font-size="25" font-weight="700" fill="#ff3f7f">Shopping</text></svg>
|
||||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#f8b4c8"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#7c3aed" opacity=".1"/><g fill="none" stroke="#7c3aed" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M135 130h130l15 140H120l15-140Zm25 10c0-42 80-42 80 0"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Bolso Crossbody Aura</text></svg>
|
||||||
|
After Width: | Height: | Size: 619 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#f7d6bf"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#ff3f7f" opacity=".1"/><g fill="none" stroke="#ff3f7f" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M155 105h90v175h-90zM175 105V72h50v33M175 175h50"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Set Glow Skincare</text></svg>
|
||||||
|
After Width: | Height: | Size: 611 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#f1d2d9"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#9f1239" opacity=".1"/><g fill="none" stroke="#9f1239" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M145 110h110v150H145zM200 185v-25m0 25 22 16"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Reloj Minimal Rose</text></svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#d7ddff"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#4f46e5" opacity=".1"/><g fill="none" stroke="#4f46e5" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M126 155h148v115H126zM155 125v66m90-66v66"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Audífonos Mini Pods</text></svg>
|
||||||
|
After Width: | Height: | Size: 607 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#d8f3ea"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#0f766e" opacity=".1"/><g fill="none" stroke="#0f766e" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M115 125h170v150H115zM115 180h170M170 125v150M230 125v150"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Organizador Vanity</text></svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#ffe0ef"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#be185d" opacity=".1"/><g fill="none" stroke="#be185d" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M145 275 205 110m-15 165 70-165"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Kit Brochas Essential</text></svg>
|
||||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#d7f4ff"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#0369a1" opacity=".1"/><g fill="none" stroke="#0369a1" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M145 105h110l-15 175h-80zM165 105V75h90"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Termo Trend 1.2L</text></svg>
|
||||||
|
After Width: | Height: | Size: 601 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="b" x2="1" y2="1"><stop stop-color="#dfe7ef"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="400" height="400" rx="42" fill="url(#b)"/><circle cx="320" cy="80" r="90" fill="#334155" opacity=".1"/><g fill="none" stroke="#334155" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"> <path d="M165 68h70v264h-70zM150 122h100v156H150z"/></g><text x="200" y="352" text-anchor="middle" font-family="Arial" font-size="19" font-weight="700" fill="#2b2131">Smart Band Active</text></svg>
|
||||||
|
After Width: | Height: | Size: 603 B |
@@ -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,
|
||||||
|
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,87 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html><html lang="es"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#ff3f7f"><meta name="description" content="Ari Shopping, productos seleccionados y compras fáciles desde Colombia."><title>Ari Shopping</title><link rel="icon" href="assets/img/brand/favicon.svg"><link rel="stylesheet" href="assets/css/styles.css"><script defer src="assets/js/app.js"></script></head><body>
|
||||||
<html lang="es">
|
<div class="announcement">Envíos nacionales · Atención personalizada · Precios en COP</div>
|
||||||
<head>
|
<header><div class="shell head"><a href="#inicio"><img src="assets/img/brand/logo.svg" alt="Ari Shopping"></a><label class="search"><span>⌕</span><input id="search" type="search" placeholder="Busca productos o categorías"></label><a class="ig" href="https://www.instagram.com/ari_shopping1/" target="_blank" rel="noopener">Instagram</a><button id="cartOpen" class="cart">Carrito <b id="cartCount">0</b></button></div></header>
|
||||||
<meta charset="utf-8">
|
<main id="inicio"><section class="hero shell"><div><span class="eyebrow">Ari Shopping · Colección destacada</span><h1>Detalles que hacen tu día más especial.</h1><p>Una experiencia de compra moderna, cercana y rápida. Descubre novedades, favoritos y productos seleccionados para ti.</p><div class="heroActions"><a class="btn primary" href="#catalogo">Ver productos</a><a class="btn ghost" href="https://www.instagram.com/ari_shopping1/" target="_blank" rel="noopener">Seguir en Instagram</a></div></div><div class="art"><div><small>Nuevos favoritos</small><strong>Estilo para cada momento</strong><span>Desde $59.900</span></div></div></section>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<section class="benefits shell"><article><b>01</b><span><strong>Compra simple</strong><small>Agrega y envía tu pedido.</small></span></article><article><b>02</b><span><strong>Catálogo dinámico</strong><small>Productos desde una fuente central.</small></span></article><article><b>03</b><span><strong>Atención directa</strong><small>Confirma disponibilidad antes de pagar.</small></span></article></section>
|
||||||
<meta name="description" content="Ari Shopping: juguetes, minifiguras, bolsos y regalos en un solo lugar.">
|
<section id="catalogo" class="catalog shell"><div class="sectionTitle"><div><span class="eyebrow">Catálogo dinámico</span><h2>Productos para descubrir</h2><p id="summary">Cargando catálogo...</p></div><select id="sort"><option value="featured">Destacados</option><option value="new">Nuevos</option><option value="asc">Precio: menor</option><option value="desc">Precio: mayor</option></select></div><div id="categories" class="categories"></div><div id="grid" class="grid"></div><div id="empty" class="empty" hidden>No encontramos productos.</div></section>
|
||||||
<meta name="theme-color" content="#121318">
|
<section class="instagram"><div class="shell"><div><span class="eyebrow">Comunidad Ari Shopping</span><h2>Novedades y promociones en Instagram.</h2></div><a class="btn light" href="https://www.instagram.com/ari_shopping1/" target="_blank" rel="noopener">@ari_shopping1</a></div></section></main>
|
||||||
<title>Ari Shopping | Tu tienda online</title>
|
<footer><div class="shell foot"><img src="assets/img/brand/logo.svg" alt="Ari Shopping"><p>Detalles que enamoran, compras que disfrutas.</p><span>Frontend v2.0.0</span></div></footer>
|
||||||
<style>
|
<div id="overlay" class="overlay" hidden></div><aside id="drawer" class="drawer" aria-hidden="true"><div class="drawerHead"><div><small>Tu selección</small><h2>Carrito</h2></div><button id="cartClose">×</button></div><div id="cartItems" class="drawerBody"></div><div class="drawerFoot"><div><span>Total estimado</span><strong id="cartTotal">$0</strong></div><button id="checkout" class="btn primary full">Enviar pedido</button><button id="clearCart" class="link">Vaciar carrito</button><small>Precio y disponibilidad se confirman antes del pago.</small></div></aside><div id="toast" class="toast" hidden></div>
|
||||||
:root{--ink:#17181d;--muted:#6c6e75;--line:#e9e6e1;--soft:#f7f5f2;--brand:#cc2b63;--brand2:#a31f4c;--gold:#f3b33f;--ok:#168252;--shadow:0 18px 55px rgba(20,20,24,.12);--r:22px;--max:1220px}
|
<template id="card"><article class="card"><div class="media"><div class="badges"></div><button class="wish" aria-label="Favorito">♡</button><img loading="lazy"></div><div class="body"><small class="category"></small><h3></h3><div class="price"><strong></strong><del></del></div><p></p><button class="btn primary add">Agregar</button></div></article></template>
|
||||||
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:#fff}button,input{font:inherit}a{color:inherit;text-decoration:none}img{display:block;width:100%;object-fit:cover}.wrap{width:min(calc(100% - 32px),var(--max));margin:auto}
|
</body></html>
|
||||||
.top{padding:9px 15px;color:#fff;background:var(--ink);font-size:.85rem;text-align:center}.top b{color:#ffd582}
|
|
||||||
header{position:sticky;top:0;z-index:30;border-bottom:1px solid rgba(233,230,225,.9);background:rgba(255,255,255,.92);backdrop-filter:blur(14px)}
|
|
||||||
.head{min-height:76px;display:grid;grid-template-columns:auto minmax(250px,1fr) auto;align-items:center;gap:24px}.brand{display:flex;align-items:center;gap:11px;white-space:nowrap}.logo{width:46px;height:46px;display:grid;place-items:center;border-radius:50%;color:#fff;background:linear-gradient(145deg,#111 20%,var(--brand));font:italic 700 1.2rem Georgia;box-shadow:0 8px 22px rgba(204,43,99,.25)}.brand strong{display:block}.brand small{display:block;color:var(--muted)}
|
|
||||||
.search{position:relative}.search span{position:absolute;left:17px;top:50%;transform:translateY(-50%);color:var(--muted)}.search input{width:100%;height:48px;padding:0 18px 0 46px;border:1px solid var(--line);border-radius:999px;outline:none;background:var(--soft)}.search input:focus{border-color:#dc86a6;box-shadow:0 0 0 4px rgba(204,43,99,.1);background:#fff}
|
|
||||||
.actions{display:flex;gap:9px}.btn,.icon{border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;font-weight:750}.btn{height:44px;padding:0 18px}.btn.primary{border-color:var(--brand);color:#fff;background:var(--brand)}.icon{position:relative;width:44px;height:44px;display:grid;place-items:center}.count{position:absolute;right:-3px;top:-5px;min-width:19px;height:19px;padding:0 5px;display:grid;place-items:center;border:2px solid #fff;border-radius:99px;color:#fff;background:var(--brand);font-size:.67rem}
|
|
||||||
nav{border-top:1px solid var(--line)}.nav{min-height:46px;display:flex;align-items:center;gap:7px;overflow:auto;scrollbar-width:none}.nav::-webkit-scrollbar{display:none}.nav button{padding:8px 14px;border:0;border-radius:999px;color:#555861;background:transparent;white-space:nowrap;cursor:pointer;font-weight:700}.nav button:hover,.nav button.active{color:var(--brand2);background:#fff0f5}
|
|
||||||
.hero{padding:42px 0 18px}.heroGrid{min-height:510px;display:grid;grid-template-columns:1.08fr .92fr;overflow:hidden;border-radius:32px;background:linear-gradient(135deg,#fff7fa,#f0dbe4);box-shadow:var(--shadow)}.heroCopy{align-self:center;padding:clamp(34px,6vw,72px)}.tag{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid rgba(204,43,99,.2);border-radius:999px;color:var(--brand2);background:rgba(255,255,255,.7);font-size:.78rem;font-weight:850;text-transform:uppercase}.dot{width:8px;height:8px;border-radius:50%;background:var(--brand)}h1{max-width:680px;margin:22px 0 0;font-size:clamp(2.7rem,5.2vw,5rem);line-height:.97;letter-spacing:-.065em}h1 em{color:var(--brand);font-family:Georgia;font-weight:500}.hero p{max-width:590px;margin:23px 0 29px;color:#555861;font-size:1.08rem;line-height:1.65}.heroActions{display:flex;flex-wrap:wrap;gap:11px}.cta{min-height:50px;padding:0 22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid rgba(23,24,29,.12);border-radius:999px;background:rgba(255,255,255,.78);font-weight:850}.cta.primary{border-color:var(--brand);color:#fff;background:var(--brand);box-shadow:0 12px 28px rgba(204,43,99,.24)}.heroMedia{position:relative;min-height:480px}.heroMedia img{height:100%;min-height:480px}.heroMedia:after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,#efdfe5 0%,transparent 25%)}.float{position:absolute;right:24px;bottom:24px;z-index:2;max-width:250px;padding:18px;border:1px solid rgba(255,255,255,.8);border-radius:18px;background:rgba(255,255,255,.91);box-shadow:var(--shadow);backdrop-filter:blur(12px)}.float strong{display:block;margin-bottom:5px}.float span{color:var(--muted);font-size:.85rem;line-height:1.45}
|
|
||||||
.trust{padding:18px 0 24px}.trustGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.trustCard{padding:18px;display:flex;gap:13px;align-items:center;border:1px solid var(--line);border-radius:15px}.trustIcon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;color:var(--brand2);background:#fff0f5;font-size:1.2rem}.trustCard strong{display:block;font-size:.93rem}.trustCard small{color:var(--muted)}
|
|
||||||
section.block{padding:72px 0}.soft{background:var(--soft)}.heading{margin-bottom:28px;display:flex;align-items:end;justify-content:space-between;gap:20px}.heading h2{margin:0;font-size:clamp(2rem,4vw,3.1rem);letter-spacing:-.05em}.heading p{max-width:520px;margin:8px 0 0;color:var(--muted);line-height:1.55}.link{color:var(--brand2);font-weight:850;white-space:nowrap}
|
|
||||||
.cats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.cat{position:relative;min-height:300px;overflow:hidden;border-radius:var(--r);cursor:pointer;box-shadow:0 8px 28px rgba(20,20,24,.07)}.cat img{height:100%;min-height:300px;transition:.35s}.cat:hover img{transform:scale(1.045)}.cat:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,rgba(8,9,11,.82))}.catText{position:absolute;left:20px;right:20px;bottom:19px;z-index:2;color:#fff}.catText strong{display:block;font-size:1.2rem}.catText small{color:rgba(255,255,255,.78)}
|
|
||||||
.filters{margin-bottom:22px;display:flex;flex-wrap:wrap;gap:8px}.filter{padding:9px 14px;border:1px solid var(--line);border-radius:999px;background:#fff;cursor:pointer;font-weight:750}.filter.active{border-color:var(--ink);color:#fff;background:var(--ink)}.products{display:grid;grid-template-columns:repeat(4,1fr);gap:20px}.card{overflow:hidden;border:1px solid var(--line);border-radius:var(--r);background:#fff;transition:.2s}.card:hover{transform:translateY(-5px);box-shadow:var(--shadow)}.pic{position:relative;aspect-ratio:1/1.05;overflow:hidden;background:#eee}.pic img{height:100%;transition:.32s}.card:hover .pic img{transform:scale(1.035)}.badge{position:absolute;left:13px;top:13px;padding:7px 10px;border-radius:99px;color:#fff;background:var(--ink);font-size:.7rem;font-weight:850}.badge.sale{background:var(--brand)}.body{padding:16px}.kicker{color:var(--brand2);font-size:.72rem;font-weight:850;text-transform:uppercase}.body h3{min-height:47px;margin:8px 0 10px;font-size:1rem;line-height:1.4}.rating{margin-bottom:12px;color:#e29a1a;font-size:.82rem}.rating span{color:var(--muted)}.foot{display:flex;align-items:center;justify-content:space-between;gap:10px}.price{font-size:1.15rem;font-weight:900}.price del{display:block;color:#9a9ba0;font-size:.75rem;font-weight:600}.add{width:42px;height:42px;border:0;border-radius:50%;color:#fff;background:var(--brand);cursor:pointer;font-size:1.3rem}.empty{grid-column:1/-1;padding:50px;text-align:center;color:var(--muted);border:1px dashed #d4d0ca;border-radius:var(--r);background:#fff}
|
|
||||||
.insta{padding:80px 0;color:#fff;background:radial-gradient(circle at 15% 0%,rgba(204,43,99,.6),transparent 36%),linear-gradient(135deg,#15161b,#28262d)}.instaGrid{display:grid;grid-template-columns:.85fr 1.15fr;gap:44px;align-items:center}.insta h2{margin:20px 0 16px;font-size:clamp(2.3rem,4.5vw,4.1rem);line-height:1;letter-spacing:-.055em}.insta p{max-width:520px;margin:0 0 25px;color:rgba(255,255,255,.75);line-height:1.65}.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.tile{aspect-ratio:1;overflow:hidden;border-radius:16px}.tile img{height:100%;transition:.3s}.tile:hover img{transform:scale(1.05)}
|
|
||||||
footer{padding:50px 0 24px;color:rgba(255,255,255,.74);background:#121318}.footerGrid{display:grid;grid-template-columns:1.35fr repeat(3,1fr);gap:35px}.footerGrid h3{margin:0 0 15px;color:#fff;font-size:.95rem}.links{display:grid;gap:10px;font-size:.87rem}.about{max-width:350px;line-height:1.6}.bottom{margin-top:38px;padding-top:20px;display:flex;justify-content:space-between;gap:20px;border-top:1px solid rgba(255,255,255,.1);font-size:.8rem}
|
|
||||||
.overlay{position:fixed;inset:0;z-index:80;opacity:0;visibility:hidden;background:rgba(8,9,11,.55);transition:.2s}.overlay.open{opacity:1;visibility:visible}.drawer{position:fixed;right:0;top:0;z-index:90;width:min(430px,100%);height:100%;padding:24px;display:flex;flex-direction:column;transform:translateX(100%);background:#fff;box-shadow:-20px 0 60px rgba(0,0,0,.2);transition:.24s}.drawer.open{transform:none}.drawerHead{padding-bottom:17px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.drawerHead h2{margin:0}.close{width:40px;height:40px;border:1px solid var(--line);border-radius:50%;background:#fff;cursor:pointer;font-size:1.2rem}.cartList{flex:1;overflow:auto}.cartEmpty{height:100%;display:grid;place-items:center;color:var(--muted);text-align:center}.cartItem{padding:14px 0;display:grid;grid-template-columns:68px 1fr auto;gap:11px;align-items:center;border-bottom:1px solid var(--line)}.cartItem img{width:68px;height:68px;border-radius:11px}.cartItem strong{display:block;font-size:.88rem}.cartItem small{color:var(--brand2);font-weight:800}.remove{border:0;color:#9f2929;background:none;cursor:pointer}.drawerFoot{padding-top:16px;border-top:1px solid var(--line)}.total{margin-bottom:14px;display:flex;justify-content:space-between;font-weight:900}.drawerFoot .cta{width:100%}.toast{position:fixed;left:50%;bottom:24px;z-index:100;padding:12px 17px;opacity:0;transform:translate(-50%,15px);border-radius:99px;color:#fff;background:var(--ink);box-shadow:var(--shadow);transition:.18s;pointer-events:none;font-weight:750}.toast.show{opacity:1;transform:translate(-50%,0)}
|
|
||||||
@media(max-width:1000px){.head{grid-template-columns:auto 1fr auto;gap:13px}.brandText{display:none}.actions .btn{display:none}.heroGrid,.instaGrid{grid-template-columns:1fr}.heroMedia,.heroMedia img{min-height:380px}.heroMedia:after{background:linear-gradient(180deg,#efdfe5 0%,transparent 22%)}.cats,.products{grid-template-columns:repeat(2,1fr)}.footerGrid{grid-template-columns:repeat(2,1fr)}}
|
|
||||||
@media(max-width:680px){.wrap{width:min(calc(100% - 20px),var(--max))}.head{padding:11px 0}.logo{width:41px;height:41px}.search input{height:42px;font-size:.85rem}.hero{padding-top:20px}.heroCopy{padding:34px 23px}.heroMedia,.heroMedia img{min-height:315px}.float{left:15px;right:15px;bottom:15px;max-width:none}.trustGrid{grid-template-columns:1fr}section.block{padding:52px 0}.heading{align-items:flex-start;flex-direction:column}.cats{display:flex;overflow:auto;scroll-snap-type:x mandatory}.cat{min-width:78vw;scroll-snap-align:start}.products{gap:12px}.body{padding:13px}.body h3{min-height:58px;font-size:.92rem}.footerGrid{grid-template-columns:1fr}.bottom{flex-direction:column}}
|
|
||||||
@media(max-width:430px){.products{grid-template-columns:1fr}.body h3{min-height:0}.heroActions .cta{width:100%}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="top"><b>Envíos Seguros</b> · Pagos fáciles · Atención personalizada</div>
|
|
||||||
<header>
|
|
||||||
<div class="wrap head">
|
|
||||||
<a class="brand" href="#inicio"><span class="logo">ARI</span><span class="brandText"><strong>Ari Shopping</strong><small>Detalles que te encantan</small></span></a>
|
|
||||||
<label class="search"><span>⌕</span><input id="search" type="search" placeholder="Buscar juguetes, bolsos, regalos..."></label>
|
|
||||||
<div class="actions"><a class="btn primary" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Escríbenos</a><button id="cartBtn" class="icon" aria-label="Abrir carrito">🛒<span id="count" class="count">0</span></button></div>
|
|
||||||
</div>
|
|
||||||
<nav><div class="wrap nav" id="nav"></div></nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main id="inicio">
|
|
||||||
<section class="hero"><div class="wrap"><div class="heroGrid">
|
|
||||||
<div class="heroCopy"><span class="tag"><span class="dot"></span>Nueva colección</span><h1>Encuentra ese detalle que hace el día más <em>especial.</em></h1><p>Juguetes, minifiguras, bolsos y regalos seleccionados para sorprender. Compra fácil y recibe atención personalizada.</p><div class="heroActions"><a class="cta primary" href="#productos">Explorar productos</a><a class="cta" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Ver Instagram</a></div></div>
|
|
||||||
<div class="heroMedia"><img src="https://images.unsplash.com/photo-1566576912321-d58ddd7a6088?auto=format&fit=crop&w=1200&q=85" alt="Juguetes coloridos"><div class="float"><strong>Compra con acompañamiento</strong><span>Te ayudamos a elegir, confirmar disponibilidad y coordinar tu envío.</span></div></div>
|
|
||||||
</div></div></section>
|
|
||||||
|
|
||||||
<section class="trust"><div class="wrap trustGrid"><article class="trustCard"><span class="trustIcon">🚚</span><div><strong>Envíos seguros</strong><small>Coordinación y seguimiento.</small></div></article><article class="trustCard"><span class="trustIcon">💳</span><div><strong>Pagos fáciles</strong><small>Opciones claras para comprar.</small></div></article><article class="trustCard"><span class="trustIcon">💗</span><div><strong>Atención cercana</strong><small>Antes y después de tu compra.</small></div></article></div></section>
|
|
||||||
|
|
||||||
<section class="block"><div class="wrap"><div class="heading"><div><h2>Compra por categoría</h2><p>Encuentra rápido el regalo, juguete o accesorio ideal.</p></div><a class="link" href="#productos">Ver todo →</a></div><div class="cats">
|
|
||||||
<article class="cat" data-cat="juguetes"><img src="https://images.unsplash.com/photo-1594787318286-3d835c1d207f?auto=format&fit=crop&w=800&q=82" alt="Juguetes"><div class="catText"><strong>Juguetes</strong><small>Diversión para todas las edades</small></div></article>
|
|
||||||
<article class="cat" data-cat="minifiguras"><img src="https://images.unsplash.com/photo-1560961911-ba7ef651a56c?auto=format&fit=crop&w=800&q=82" alt="Minifiguras"><div class="catText"><strong>Minifiguras</strong><small>Personajes para coleccionar</small></div></article>
|
|
||||||
<article class="cat" data-cat="bolsos"><img src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=800&q=82" alt="Mochilas"><div class="catText"><strong>Bolsos y mochilas</strong><small>Estilo para cada aventura</small></div></article>
|
|
||||||
<article class="cat" data-cat="regalos"><img src="https://images.unsplash.com/photo-1549465220-1a8b9238cd48?auto=format&fit=crop&w=800&q=82" alt="Regalos"><div class="catText"><strong>Regalos</strong><small>Detalles que emocionan</small></div></article>
|
|
||||||
</div></div></section>
|
|
||||||
|
|
||||||
<section id="productos" class="block soft"><div class="wrap"><div class="heading"><div><h2>Productos destacados</h2><p>Catálogo demostrativo listo para conectarse después con el microservicio de productos.</p></div><a class="link" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Consultar disponibilidad →</a></div><div id="filters" class="filters"></div><div id="products" class="products"></div></div></section>
|
|
||||||
|
|
||||||
<section class="insta"><div class="wrap instaGrid"><div><span class="tag"><span class="dot"></span>@ari_shopping1</span><h2>Descubre novedades todos los días.</h2><p>Sigue la cuenta para conocer productos recién llegados, promociones y disponibilidad. La tienda online transformará esa comunidad en una experiencia de compra completa.</p><a class="cta primary" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Seguir en Instagram</a></div><div class="tiles"><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&w=500&q=80" alt="Producto"></a><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1594787318286-3d835c1d207f?auto=format&fit=crop&w=500&q=80" alt="Juguetes"></a><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=500&q=80" alt="Mochila"></a><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1607453998774-d533f65dac99?auto=format&fit=crop&w=500&q=80" alt="Regalo"></a><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1566576912321-d58ddd7a6088?auto=format&fit=crop&w=500&q=80" alt="Juguetes"></a><a class="tile" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/"><img src="https://images.unsplash.com/photo-1549465220-1a8b9238cd48?auto=format&fit=crop&w=500&q=80" alt="Regalos"></a></div></div></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer><div class="wrap"><div class="footerGrid"><div><a class="brand" href="#inicio"><span class="logo">ARI</span><span><strong style="color:#fff">Ari Shopping</strong><small style="display:block;color:#aaa">Tu tienda virtual</small></span></a><p class="about">Variedad de productos y marcas en un solo lugar, con atención personalizada y una experiencia de compra cada vez más completa.</p></div><div><h3>Comprar</h3><div class="links"><a href="#productos">Productos</a><a href="#productos">Novedades</a><a href="#productos">Ofertas</a></div></div><div><h3>Ayuda</h3><div class="links"><a target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Contacto</a><a href="#">Envíos</a><a href="#">Cambios y devoluciones</a></div></div><div><h3>Síguenos</h3><div class="links"><a target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Instagram @ari_shopping1</a><a href="#">WhatsApp próximamente</a></div></div></div><div class="bottom"><span>© <span id="year"></span> Ari Shopping.</span><span>Front MVP listo para evolución a microservicios.</span></div></div></footer>
|
|
||||||
|
|
||||||
<div id="overlay" class="overlay"></div><aside id="drawer" class="drawer" aria-hidden="true"><div class="drawerHead"><h2>Tu carrito</h2><button id="close" class="close">×</button></div><div id="cartList" class="cartList"></div><div class="drawerFoot"><div class="total"><span>Total estimado</span><span id="total">$0</span></div><a class="cta primary" target="_blank" rel="noopener" href="https://www.instagram.com/ari_shopping1/">Finalizar por Instagram</a></div></aside><div id="toast" class="toast"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const data=[
|
|
||||||
{id:1,n:'Figura coleccionable edición especial',c:'minifiguras',tag:'Nuevo',p:79000,old:0,img:'https://images.unsplash.com/photo-1560961911-ba7ef651a56c?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:2,n:'Set creativo de bloques y personajes',c:'juguetes',tag:'Favorito',p:119000,old:139000,img:'https://images.unsplash.com/photo-1566576912321-d58ddd7a6088?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:3,n:'Mochila urbana compacta',c:'bolsos',tag:'Nuevo',p:89000,old:0,img:'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:4,n:'Caja sorpresa para regalo',c:'regalos',tag:'Oferta',p:59000,old:69000,img:'https://images.unsplash.com/photo-1549465220-1a8b9238cd48?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:5,n:'Vehículo de juguete colección',c:'juguetes',tag:'Nuevo',p:69000,old:0,img:'https://images.unsplash.com/photo-1594787318286-3d835c1d207f?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:6,n:'Kit de arte y creatividad',c:'regalos',tag:'Favorito',p:74000,old:0,img:'https://images.unsplash.com/photo-1607453998774-d533f65dac99?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:7,n:'Figura decorativa de colección',c:'minifiguras',tag:'Oferta',p:65000,old:79000,img:'https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&w=800&q=82'},
|
|
||||||
{id:8,n:'Bolso casual de uso diario',c:'bolsos',tag:'Nuevo',p:99000,old:0,img:'https://images.unsplash.com/photo-1559563458-527698bf5295?auto=format&fit=crop&w=800&q=82'}
|
|
||||||
];
|
|
||||||
const labels={todos:'Todos',nuevo:'Lo nuevo',juguetes:'Juguetes',minifiguras:'Minifiguras',bolsos:'Bolsos',regalos:'Regalos',ofertas:'Ofertas'},state={f:'todos',q:'',cart:[]},money=new Intl.NumberFormat('es-CO',{style:'currency',currency:'COP',maximumFractionDigits:0});
|
|
||||||
const $=s=>document.querySelector(s), nav=$('#nav'),filters=$('#filters'),grid=$('#products'),search=$('#search'),drawer=$('#drawer'),overlay=$('#overlay'),cartList=$('#cartList');
|
|
||||||
function match(x,f){return f==='todos'||f==='nuevo'&&x.tag==='Nuevo'||f==='ofertas'&&x.old||x.c===f}function renderNav(){nav.innerHTML=Object.entries(labels).map(([k,v])=>`<button data-filter="${k}" class="${state.f===k?'active':''}">${v}</button>`).join('');filters.innerHTML=Object.entries(labels).map(([k,v])=>`<button data-filter="${k}" class="filter ${state.f===k?'active':''}">${v}</button>`).join('')}
|
|
||||||
function render(){renderNav();const q=state.q.trim().toLowerCase(),rows=data.filter(x=>match(x,state.f)&&(!q||(x.n+' '+x.c).toLowerCase().includes(q)));grid.innerHTML=rows.length?rows.map(x=>`<article class="card"><div class="pic"><img loading="lazy" src="${x.img}" alt="${x.n}"><span class="badge ${x.old?'sale':''}">${x.tag}</span></div><div class="body"><span class="kicker">${labels[x.c]}</span><h3>${x.n}</h3><div class="rating">★★★★★ <span>4.8</span></div><div class="foot"><div class="price">${money.format(x.p)}${x.old?`<del>${money.format(x.old)}</del>`:''}</div><button class="add" data-add="${x.id}" aria-label="Agregar">+</button></div></div></article>`).join(''):'<div class="empty"><strong>No encontramos productos.</strong><br>Prueba otra búsqueda o categoría.</div>'}
|
|
||||||
function toast(t){const el=$('#toast');el.textContent=t;el.classList.add('show');clearTimeout(toast.t);toast.t=setTimeout(()=>el.classList.remove('show'),2000)}function add(id){const x=data.find(p=>p.id===id),e=state.cart.find(p=>p.id===id);e?e.qty++:state.cart.push({...x,qty:1});renderCart();toast('Producto agregado al carrito')}function remove(id){state.cart=state.cart.filter(x=>x.id!==id);renderCart()}
|
|
||||||
function renderCart(){const n=state.cart.reduce((s,x)=>s+x.qty,0),t=state.cart.reduce((s,x)=>s+x.p*x.qty,0);$('#count').textContent=n;$('#total').textContent=money.format(t);cartList.innerHTML=state.cart.length?state.cart.map(x=>`<div class="cartItem"><img src="${x.img}" alt="${x.n}"><div><strong>${x.n}</strong><small>${money.format(x.p)} · Cant. ${x.qty}</small></div><button class="remove" data-remove="${x.id}">Eliminar</button></div>`).join(''):'<div class="cartEmpty"><div><strong>Tu carrito está vacío</strong><br><small>Agrega productos para comenzar.</small></div></div>'}
|
|
||||||
function openCart(){drawer.classList.add('open');overlay.classList.add('open');drawer.setAttribute('aria-hidden','false')}function closeCart(){drawer.classList.remove('open');overlay.classList.remove('open');drawer.setAttribute('aria-hidden','true')}
|
|
||||||
document.addEventListener('click',e=>{const f=e.target.closest('[data-filter]'),cat=e.target.closest('[data-cat]'),a=e.target.closest('[data-add]'),r=e.target.closest('[data-remove]');if(f||cat){state.f=(f?.dataset.filter||cat.dataset.cat);render();$('#productos').scrollIntoView({behavior:'smooth'})}if(a)add(+a.dataset.add);if(r)remove(+r.dataset.remove)});search.addEventListener('input',e=>{state.q=e.target.value;render()});$('#cartBtn').onclick=openCart;$('#close').onclick=closeCart;overlay.onclick=closeCart;document.addEventListener('keydown',e=>{if(e.key==='Escape')closeCart()});$('#year').textContent=new Date().getFullYear();render();renderCart();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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; }
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow==11.3.0
|
||||||