Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17dc9a5133 | ||
|
|
ba96a50a42 | ||
|
|
91335ce023 | ||
|
|
43c47d99fb | ||
|
|
4baabf36ce | ||
|
|
3cc0a7c861 | ||
|
|
a3e9580348 | ||
|
|
aaf6e14742 | ||
|
|
c23803656e | ||
|
|
e86a0ebc96 | ||
|
|
b02a5e8b5f | ||
|
|
ca10ef6e9a | ||
|
|
f6918b9eab | ||
|
|
8e4f3515fe | ||
|
|
e7d7fcdb00 | ||
|
|
8f9df266d3 | ||
|
|
ac7f55eab3 | ||
|
|
f3f6fb7166 | ||
|
|
619228168d | ||
|
|
7f0c1571d9 | ||
|
|
cfd9a7ec96 | ||
|
|
472c12ec0a | ||
|
|
271ea30f44 | ||
|
|
d1fa58bf24 | ||
|
|
8145e90441 | ||
|
|
e6f031a366 | ||
|
|
503f56977d | ||
|
|
f8304633c1 | ||
|
|
a25cad6da3 | ||
|
|
cbdb44c7dc | ||
|
|
09c756942b | ||
|
|
a9ef0ed00a | ||
|
|
da7f316eb1 | ||
|
|
123cbbd87a | ||
|
|
496ad982f3 | ||
|
|
fa183a7115 | ||
|
|
4146245ae9 | ||
|
|
a38edfecc8 | ||
|
|
2566e06abd | ||
|
|
c7ac1b52be | ||
|
|
2e7a3f3d24 | ||
|
|
d9c2037c59 | ||
|
|
7549dc86d2 | ||
|
|
8e0d6abb82 | ||
|
|
d98ec9a488 | ||
|
|
03cfb203eb | ||
|
|
4553967bec | ||
|
|
059597346d | ||
|
|
b55766847f | ||
|
|
848bac0b2d | ||
|
|
8107f9b6c7 | ||
|
|
d82f4df83a | ||
|
|
55eb9d8051 | ||
|
|
fb89c26919 | ||
|
|
c245f3b67a | ||
|
|
149f743975 | ||
|
|
cc79d3b089 | ||
|
|
5bcacdf09d | ||
|
|
3e5e18c14f | ||
|
|
e75a11d554 | ||
|
|
0fd05dab2a | ||
|
|
5fe18ce3d4 | ||
|
|
2318e1da7d | ||
|
|
b6b2f15c12 | ||
|
|
7658b9879a | ||
|
|
a1dc145528 | ||
|
|
4f5a715812 | ||
|
|
5ce9c07829 | ||
|
|
428ad83e5b | ||
|
|
80862bef5b | ||
|
|
777e47ce16 | ||
|
|
167ae4057a | ||
|
|
51300c234a | ||
|
|
0c0efe3727 | ||
|
|
e4dafe9017 | ||
|
|
fed5dfc1c1 | ||
|
|
5773154db9 | ||
|
|
553b1c7a32 | ||
|
|
d9c39ba2a1 | ||
|
|
164223e844 | ||
|
|
ee327b420c | ||
|
|
17348e6285 | ||
|
|
6c6303cf8e | ||
|
|
410975f972 | ||
|
|
291db05a5e | ||
|
|
73ff01c84a | ||
|
|
5933279713 | ||
|
|
a77a6f2fd0 | ||
|
|
e3c27de736 | ||
|
|
cc7ddddf58 | ||
|
|
7dd2c139cb | ||
|
|
fe4ed8005a | ||
|
|
009d5c63df | ||
|
|
4148f14128 | ||
|
|
b3b7ecc77a | ||
|
|
d3e0d78314 | ||
|
|
5c79cc3345 | ||
|
|
972b11772a | ||
|
|
90176e36fc | ||
|
|
5b8bac5ef1 | ||
|
|
545062c3ef | ||
|
|
4009fde072 | ||
|
|
0723b43c54 | ||
|
|
58a1fd1597 | ||
|
|
8889fd9ea2 | ||
|
|
567ee30e1f | ||
|
|
cd72962fa4 | ||
|
|
af9576f45d | ||
|
|
1c9693e573 | ||
|
|
f92e8e6b36 | ||
|
|
ccaddf546c | ||
|
|
8d8546fd94 | ||
|
|
57acdb0ed3 | ||
|
|
5a2d0c061c | ||
|
|
c3f3857a92 | ||
|
|
9b545dd850 | ||
|
|
401f43a136 | ||
|
|
b23f19ac6b | ||
|
|
9d4553f3a4 | ||
|
|
d605417452 | ||
|
|
706732878f | ||
|
|
c222a6fed6 | ||
|
|
8d16f2b854 | ||
|
|
f0b1d7b745 | ||
|
|
9ab779e781 | ||
|
|
2fa404fe7b | ||
|
|
69cc254051 | ||
|
|
76b7d827c5 | ||
|
|
3efab94e2b | ||
|
|
71c8752eeb | ||
|
|
cda25dea2e | ||
|
|
392a832109 | ||
|
|
a069807960 | ||
|
|
96b08eda01 | ||
|
|
e6c3e0ed8d | ||
|
|
35fdea5df5 | ||
|
|
8d44f2e771 | ||
|
|
ef6dc1f6a8 | ||
|
|
352762b9d2 | ||
|
|
58cc93bb2f | ||
|
|
5eaa94818f | ||
|
|
66a81ad9a3 | ||
|
|
39e1bf61d3 | ||
|
|
b5a1f70d1d | ||
|
|
9d231322b8 | ||
|
|
098ff990a6 | ||
|
|
ff59618a2a | ||
|
|
44e343eca7 | ||
|
|
c77e958f04 | ||
|
|
711bb2c76f | ||
|
|
08fc4174cb | ||
|
|
89382f057c | ||
|
|
1abc69825c | ||
|
|
1e6009daf7 | ||
|
|
7a95957945 | ||
|
|
a973b2671e | ||
|
|
bdd7c06a32 | ||
|
|
0c356d6fbe | ||
|
|
c507da8abc | ||
|
|
e42d588514 | ||
|
|
e2938248f8 | ||
|
|
4992da394b | ||
|
|
f2e0c02013 | ||
|
|
d4716382d3 | ||
|
|
12c4afa09c | ||
|
|
f0b4245676 | ||
|
|
e9e78e64c1 | ||
|
|
95cf791737 | ||
|
|
2ceea23f40 | ||
|
|
4d1804eeeb | ||
|
|
9b8b96fd02 | ||
|
|
313bb2eab4 | ||
|
|
710da06c0a | ||
|
|
9d08169092 | ||
|
|
1aa7802a61 | ||
|
|
b83722e9c5 | ||
|
|
8d543f5eb1 | ||
|
|
df791e0c28 | ||
|
|
feda1283d4 | ||
|
|
8fd0514e76 | ||
|
|
1cde29b344 | ||
|
|
8517102b63 | ||
|
|
7773bcdecf | ||
|
|
c62ad626cc | ||
|
|
8835d8ca0c | ||
|
|
997a00c284 | ||
|
|
0fb904b9ac | ||
|
|
a5ede05d96 | ||
|
|
63746eb090 | ||
|
|
2ca00d032d | ||
|
|
cb15749448 | ||
|
|
9e1adf4627 | ||
|
|
0ad74b6b70 | ||
|
|
d1d007fc7d | ||
|
|
11ac6d7949 | ||
|
|
093ae419d5 | ||
|
|
2b0d50c3d0 | ||
|
|
8126bb3d81 | ||
|
|
dedf4f0963 | ||
|
|
1cd1078cdf | ||
|
|
601378a77e | ||
|
|
1dccd0aa6a | ||
|
|
474ac3795b | ||
|
|
bb2ad3c317 | ||
|
|
578f085334 | ||
|
|
ce4842f375 | ||
|
|
2cce671049 | ||
|
|
22f97ee12e | ||
|
|
42c90d8534 | ||
|
|
cd8d2edd8f | ||
|
|
77389aea19 | ||
|
|
7ce11ecbb4 | ||
|
|
da10a4b688 | ||
|
|
0b7dc7b6de | ||
|
|
2601e5e83e | ||
|
|
fecac320d9 | ||
|
|
811e0e0887 | ||
|
|
aee6515890 | ||
|
|
2e47f7b55d | ||
|
|
54be99fa2c | ||
|
|
bc530c98e6 | ||
|
|
3b748cc014 | ||
|
|
73f9424331 | ||
|
|
0917c966cb | ||
|
|
61024b8f31 | ||
|
|
7dc54a1d20 | ||
|
|
d05112ae65 |
No files matched your search
@@ -234,11 +234,17 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# --timeout 30m (no 15m): medido en vivo 2026-09-27, el analisis de
|
||||||
|
# capas de esta imagen (node_modules de Medusa) toma ~14 min en
|
||||||
|
# el runner del NAS -- con 15m hizo timeout una vez. El paso HIGH
|
||||||
|
# de abajo reusa ese analisis cacheado (~2 s), por eso no lo
|
||||||
|
# necesita. No cambia que revisa ni que bloquea.
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
--timeout 15m0s \
|
--timeout 30m0s \
|
||||||
--ignorefile "${APP_DIR}/.trivyignore" \
|
--ignorefile "${APP_DIR}/.trivyignore" \
|
||||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
@@ -248,6 +254,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
|||||||
@@ -126,8 +126,20 @@ jobs:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
APP_DIR: workloads/ecommerce
|
APP_DIR: workloads/ecommerce
|
||||||
MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
|
# Cambio 2026-09-14: todo push a main promueve a STAGING
|
||||||
KUSTOMIZE_DIR: workloads/ecommerce/frontend
|
# automaticamente (workloads/ecommerce-staging), no a Produccion
|
||||||
|
# directo -- decision del usuario para dejar de probar cambios en
|
||||||
|
# vivo contra Prod.
|
||||||
|
# Cambio 2026-09-20: la promocion de Staging a Produccion (antes
|
||||||
|
# scripts/promote-to-production.sh, manual con confirmacion
|
||||||
|
# interactiva) ahora corre sola en este mismo job -- ver el step
|
||||||
|
# "Promover a Produccion (CD)" mas abajo, justo despues del de
|
||||||
|
# Staging. Decision explicita del usuario: cero gate humano entre
|
||||||
|
# Staging y Produccion. El script manual queda solo como
|
||||||
|
# herramienta de rollback/excepcion, ya no es el camino normal.
|
||||||
|
MANIFEST_FILE: workloads/ecommerce-staging/frontend/patches/deployment.yaml
|
||||||
|
PROD_MANIFEST_FILE: workloads/ecommerce/frontend/patches/deployment.yaml
|
||||||
|
KUSTOMIZE_DIR: workloads/ecommerce-staging/frontend
|
||||||
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
|
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -261,11 +273,20 @@ jobs:
|
|||||||
|
|
||||||
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
|
echo "Validando remontaje por parámetros: ${PAGE_FILE}"
|
||||||
|
|
||||||
grep -q 'key={`${category}:${collection}:${query}`}' "${PAGE_FILE}" || {
|
# Prefijo, no igualdad exacta: desde franquicias/personajes
|
||||||
|
# (2026-09-27) la key tambien incluye ?franquicia/?saga/?personaje
|
||||||
|
# -- lo que importa es que siga remontando por categoria,
|
||||||
|
# coleccion y busqueda, y ahora tambien por la taxonomia.
|
||||||
|
grep -q 'key={`${category}:${collection}:${query}' "${PAGE_FILE}" || {
|
||||||
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
|
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grep -q 'taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}' "${PAGE_FILE}" || {
|
||||||
|
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar franquicia/saga/personaje."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
echo "OK: navegación real del catálogo validada."
|
echo "OK: navegación real del catálogo validada."
|
||||||
|
|
||||||
- name: Instalar Trivy
|
- name: Instalar Trivy
|
||||||
@@ -408,6 +429,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
@@ -421,6 +443,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
@@ -579,6 +602,13 @@ jobs:
|
|||||||
|
|
||||||
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}"
|
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${MANIFEST_FILE}"
|
||||||
|
|
||||||
|
# El footer (workloads/ecommerce/components/layout/footer.tsx) lee
|
||||||
|
# process.env.APP_VERSION para mostrar la versión real desplegada
|
||||||
|
# -- este bump la mantiene igual al tag de la imagen de arriba.
|
||||||
|
# -0777 (slurp): el nombre y el valor están en líneas distintas del
|
||||||
|
# YAML, un sed de una sola línea no puede verlos juntos.
|
||||||
|
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${VERSION}/" "${MANIFEST_FILE}"
|
||||||
|
|
||||||
git add "${MANIFEST_FILE}"
|
git add "${MANIFEST_FILE}"
|
||||||
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
@@ -592,6 +622,48 @@ jobs:
|
|||||||
# sobrescribir commits ajenos o promocionar una versión antigua.
|
# sobrescribir commits ajenos o promocionar una versión antigua.
|
||||||
git push origin HEAD:main
|
git push origin HEAD:main
|
||||||
|
|
||||||
|
# Automatiza lo que antes era scripts/promote-to-production.sh (paso
|
||||||
|
# manual con confirmación interactiva, read -rp). Decisión del
|
||||||
|
# usuario 2026-09-20: cero pasos manuales entre Staging y
|
||||||
|
# Producción — la misma imagen que acaba de promocionar a Staging
|
||||||
|
# arriba se promueve sola a Producción, sin gate humano en el
|
||||||
|
# medio. Mismo sed/perl que el script manual (mismo MANIFEST, otro
|
||||||
|
# archivo), corriendo como un segundo commit separado justo
|
||||||
|
# después del de Staging — no se pisan en un solo commit porque
|
||||||
|
# Staging y Producción son releases conceptualmente distintos
|
||||||
|
# (permite revertir uno sin tocar el otro).
|
||||||
|
- name: Promover a Producción (CD)
|
||||||
|
if: steps.promotion.outputs.promote == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
git config user.name "Cristian Felipe"
|
||||||
|
git config user.email "[email protected]"
|
||||||
|
|
||||||
|
# Re-fetch: el step de Staging de arriba ya pusheó un commit a
|
||||||
|
# main en este mismo job -- sin este refresh, este checkout
|
||||||
|
# local queda un commit atrás y el push de abajo se rechaza.
|
||||||
|
git fetch origin main
|
||||||
|
git checkout -B main origin/main
|
||||||
|
|
||||||
|
sed -i -E "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" "${PROD_MANIFEST_FILE}"
|
||||||
|
|
||||||
|
perl -0777 -pi -e "s/(- name: APP_VERSION\n\s*value: ).*/\${1}${VERSION}/" "${PROD_MANIFEST_FILE}"
|
||||||
|
|
||||||
|
git add "${PROD_MANIFEST_FILE}"
|
||||||
|
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Producción ya está en ${VERSION}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit -m "chore: promote ${VERSION} to production [skip ci]"
|
||||||
|
|
||||||
|
git push origin HEAD:main
|
||||||
|
|
||||||
- name: Resumen del pipeline
|
- name: Resumen del pipeline
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity CRITICAL \
|
--severity CRITICAL \
|
||||||
--exit-code 1 \
|
--exit-code 1 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
@@ -243,6 +244,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
/tmp/trivy image \
|
/tmp/trivy image \
|
||||||
|
--scanners vuln \
|
||||||
--severity HIGH \
|
--severity HIGH \
|
||||||
--exit-code 0 \
|
--exit-code 0 \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
name: ecommerce-staging-app
|
||||||
|
namespace: argocd
|
||||||
|
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/apps-registry.git
|
||||||
|
targetRevision: main
|
||||||
|
path: workloads/ecommerce-staging
|
||||||
|
kustomize: {}
|
||||||
|
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: ecommerce-staging
|
||||||
|
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: ecommerce-staging-governance
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/platform-infra.git
|
||||||
|
path: ecommerce-staging-governance
|
||||||
|
targetRevision: main
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: ecommerce-staging
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -23,7 +23,26 @@ ENV CI=true \
|
|||||||
S3_BUCKET=ari-shopping \
|
S3_BUCKET=ari-shopping \
|
||||||
S3_ENDPOINT=http://127.0.0.1:9000 \
|
S3_ENDPOINT=http://127.0.0.1:9000 \
|
||||||
MEILISEARCH_HOST=http://127.0.0.1:7700 \
|
MEILISEARCH_HOST=http://127.0.0.1:7700 \
|
||||||
MEILISEARCH_API_KEY=build-only-meili-key
|
MEILISEARCH_API_KEY=build-only-meili-key \
|
||||||
|
GOOGLE_CLIENT_ID=build-only-google-client-id \
|
||||||
|
GOOGLE_CLIENT_SECRET=build-only-google-client-secret \
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback \
|
||||||
|
EPAYCO_PUBLIC_KEY=build-only-epayco-public \
|
||||||
|
EPAYCO_PRIVATE_KEY=build-only-epayco-private \
|
||||||
|
EPAYCO_P_CUST_ID_CLIENTE=build-only-epayco-pcust \
|
||||||
|
EPAYCO_P_KEY=build-only-epayco-pkey \
|
||||||
|
EPAYCO_ENV=sandbox \
|
||||||
|
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
|
||||||
|
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \
|
||||||
|
ENVIOCLICK_API_KEY=build-only-envioclick-key \
|
||||||
|
ENVIOCLICK_SANDBOX=true \
|
||||||
|
ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \
|
||||||
|
ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \
|
||||||
|
ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \
|
||||||
|
ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \
|
||||||
|
ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \
|
||||||
|
ENVIOCLICK_ORIGIN_EMAIL=[email protected] \
|
||||||
|
ENVIOCLICK_ORIGIN_PHONE=3000000000
|
||||||
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,50 @@ module.exports = defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
|
// Reseñas de productos (compradores verificados + moderacion del
|
||||||
|
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/product-review",
|
||||||
|
},
|
||||||
|
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
||||||
|
// un modulo de fulfillment con el provider manual_manual
|
||||||
|
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
||||||
|
// directo) -- pero como este bloque propio comparte el mismo tipo de
|
||||||
|
// modulo, transformModules() lo pisa por completo (indexa por
|
||||||
|
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
|
||||||
|
// manual_manual va explicito ACA tambien: si se omitiera, la
|
||||||
|
// shipping option existente que ya usa ese provider se rompe en
|
||||||
|
// silencio. envioclick-fulfillment implementa el mismo contrato
|
||||||
|
// (AbstractFulfillmentProviderService) contra
|
||||||
|
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
|
||||||
|
// con una llamada de prueba real que devolvio tarifas reales de
|
||||||
|
// varias transportadoras.
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment-manual",
|
||||||
|
id: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/envioclick-fulfillment",
|
||||||
|
id: "standard",
|
||||||
|
options: {
|
||||||
|
apiKey: required("ENVIOCLICK_API_KEY"),
|
||||||
|
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
|
||||||
|
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "76001000",
|
||||||
|
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
|
||||||
|
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
|
||||||
|
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
|
||||||
|
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
|
||||||
|
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
|
||||||
|
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
resolve: "@medusajs/medusa/file",
|
resolve: "@medusajs/medusa/file",
|
||||||
options: {
|
options: {
|
||||||
@@ -157,6 +201,63 @@ module.exports = defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
// emailpass se declara explicito junto con google -- una vez
|
||||||
|
// que el modulo auth se customiza, la doc no deja claro si el
|
||||||
|
// provider default (emailpass) se sigue registrando solo.
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth-emailpass",
|
||||||
|
id: "emailpass",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth-google",
|
||||||
|
id: "google",
|
||||||
|
options: {
|
||||||
|
clientId: required("GOOGLE_CLIENT_ID"),
|
||||||
|
clientSecret: required("GOOGLE_CLIENT_SECRET"),
|
||||||
|
callbackUrl: required("GOOGLE_CALLBACK_URL"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ADR 0009: proveedor de pago -- tercera vuelta. PayU LatAm fue la
|
||||||
|
// decision original, pero Rapyd compro ese negocio en 2023 y el
|
||||||
|
// registro real del usuario quedo con credenciales de Rapyd
|
||||||
|
// (incompatibles, API completamente distinta). Se evaluo Rapyd en
|
||||||
|
// vivo y se termino descartando: comision mas alta que otras
|
||||||
|
// opciones para el volumen real del usuario. ePayco elegido en su
|
||||||
|
// lugar -- 100% de Davivienda desde 2024, comision PSE mas baja,
|
||||||
|
// tarifa preferencial con cuenta Davivienda (que el usuario ya
|
||||||
|
// tiene), sin exigir Bancolombia/Nequi como si exige Wompi. Tampoco
|
||||||
|
// existe plugin real de Medusa para ePayco, modulo custom propio.
|
||||||
|
// Alcance inicial: solo PSE, no tarjetas/otros metodos.
|
||||||
|
// provider_id resultante: "pp_epayco_pse" (identifier "epayco" + id
|
||||||
|
// "pse", convencion de Medusa).
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/payment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/epayco-pse",
|
||||||
|
id: "pse",
|
||||||
|
options: {
|
||||||
|
publicKey: required("EPAYCO_PUBLIC_KEY"),
|
||||||
|
privateKey: required("EPAYCO_PRIVATE_KEY"),
|
||||||
|
pCustIdCliente: required("EPAYCO_P_CUST_ID_CLIENTE"),
|
||||||
|
pKey: required("EPAYCO_P_KEY"),
|
||||||
|
env: process.env.EPAYCO_ENV || "sandbox",
|
||||||
|
responseUrl: required("EPAYCO_RESPONSE_URL"),
|
||||||
|
notifyUrl: required("EPAYCO_NOTIFY_URL"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
|
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
|
||||||
|
|||||||
+213
-1
@@ -13,7 +13,8 @@
|
|||||||
"@medusajs/cli": "2.18.0",
|
"@medusajs/cli": "2.18.0",
|
||||||
"@medusajs/framework": "2.18.0",
|
"@medusajs/framework": "2.18.0",
|
||||||
"@medusajs/medusa": "2.18.0",
|
"@medusajs/medusa": "2.18.0",
|
||||||
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
|
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
|
||||||
|
"epayco-sdk-node": "^1.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@swc/core": "^1.7.28",
|
"@swc/core": "^1.7.28",
|
||||||
@@ -9200,6 +9201,15 @@
|
|||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/component-emitter": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/compressible": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
@@ -9363,6 +9373,12 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookiejar": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/copy-to-clipboard": {
|
"node_modules/copy-to-clipboard": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
|
||||||
@@ -9372,6 +9388,12 @@
|
|||||||
"toggle-selection": "^1.0.6"
|
"toggle-selection": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cors": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.6",
|
"version": "2.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
@@ -9434,6 +9456,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/crypto-random-string": {
|
"node_modules/crypto-random-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||||
@@ -9806,6 +9835,35 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/epayco-sdk-node": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/epayco-sdk-node/-/epayco-sdk-node-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-i6SpQru6Hwp096yxAVaD4lsV76ASB0cqAbJsvXQoc0leCsePHRjetV/9C1AASw8ZFbnkmTajw9DhWsAvjdTEFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.2",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"node-fetch": "^2.6.0",
|
||||||
|
"superagent": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/epayco-sdk-node/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/epayco-sdk-node/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -10099,6 +10157,12 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -10292,6 +10356,16 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formidable": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==",
|
||||||
|
"deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -11207,6 +11281,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -12164,6 +12244,26 @@
|
|||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp-build-optional-packages": {
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
@@ -12924,6 +13024,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -14354,6 +14460,90 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superagent": {
|
||||||
|
"version": "3.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
|
||||||
|
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
|
||||||
|
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"component-emitter": "^1.2.0",
|
||||||
|
"cookiejar": "^2.1.0",
|
||||||
|
"debug": "^3.1.0",
|
||||||
|
"extend": "^3.0.0",
|
||||||
|
"form-data": "^2.3.1",
|
||||||
|
"formidable": "^1.2.0",
|
||||||
|
"methods": "^1.1.1",
|
||||||
|
"mime": "^1.4.1",
|
||||||
|
"qs": "^6.5.1",
|
||||||
|
"readable-stream": "^2.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/form-data": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"safe-buffer": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/readable-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/superagent/node_modules/string_decoder/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -14635,6 +14825,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/triple-beam": {
|
"node_modules/triple-beam": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||||
@@ -15099,6 +15295,22 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"@medusajs/cli": "2.18.0",
|
"@medusajs/cli": "2.18.0",
|
||||||
"@medusajs/framework": "2.18.0",
|
"@medusajs/framework": "2.18.0",
|
||||||
"@medusajs/medusa": "2.18.0",
|
"@medusajs/medusa": "2.18.0",
|
||||||
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
|
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
|
||||||
|
"epayco-sdk-node": "^1.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@swc/core": "^1.7.28",
|
"@swc/core": "^1.7.28",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Medusa from "@medusajs/js-sdk";
|
||||||
|
|
||||||
|
// baseUrl "/" -- esta pagina se sirve dentro del mismo dashboard admin
|
||||||
|
// (mismo origen que la API), no hace falta apuntar a otra URL. auth
|
||||||
|
// "session" reusa la cookie de sesion ya autenticada del dashboard.
|
||||||
|
export const sdk = new Medusa({
|
||||||
|
baseUrl: "/",
|
||||||
|
auth: { type: "session" },
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||||
|
import { ArrowDownTray, ArrowUpTray, CheckCircleSolid, XCircleSolid } from "@medusajs/icons";
|
||||||
|
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { sdk } from "../../lib/sdk";
|
||||||
|
|
||||||
|
// Importador masivo via CSV -- pensado para cargar varios productos de
|
||||||
|
// una sola vez (comprados en Amazon/eBay/etc, no dropshipping
|
||||||
|
// automatizado -- ver conversacion sobre por que se descarto raspar
|
||||||
|
// tiendas de terceros). Las fotos se descargan del origen y se re-alojan
|
||||||
|
// en el mismo MinIO/S3 del NAS que ya usa el resto del catalogo.
|
||||||
|
|
||||||
|
type RowResult = {
|
||||||
|
row: number;
|
||||||
|
title: string;
|
||||||
|
ok: boolean;
|
||||||
|
action?: "created" | "updated";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
window.open("/admin/product-import", "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductImportPanel = () => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<RowResult[] | null>(null);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
const csv = await file.text();
|
||||||
|
setLoading(true);
|
||||||
|
setResults(null);
|
||||||
|
try {
|
||||||
|
const data = await sdk.client.fetch<{ results?: RowResult[]; error?: string }>("/admin/product-import", {
|
||||||
|
method: "post",
|
||||||
|
body: { csv },
|
||||||
|
});
|
||||||
|
if (data.error) {
|
||||||
|
toast.error("No se pudo procesar el archivo", { description: data.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResults(data.results ?? []);
|
||||||
|
const okCount = data.results?.filter((r) => r.ok).length ?? 0;
|
||||||
|
const failCount = (data.results?.length ?? 0) - okCount;
|
||||||
|
if (failCount === 0) {
|
||||||
|
toast.success(`${okCount} productos importados`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`${okCount} importados, ${failCount} con error`, {
|
||||||
|
description: "Revisa el detalle abajo.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("No se pudo procesar el archivo", { description: (error as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Importación de productos</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Carga varios productos a la vez desde un archivo CSV, con fotos, descripción y precio.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="small" variant="secondary" onClick={downloadTemplate}>
|
||||||
|
<ArrowDownTray />
|
||||||
|
Descargar plantilla
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="primary" isLoading={loading} onClick={() => inputRef.current?.click()}>
|
||||||
|
<ArrowUpTray />
|
||||||
|
Subir CSV
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Columnas: <code>titulo, descripcion, precio, categoria, coleccion, cantidad, fotos</code>. Varias fotos se
|
||||||
|
separan con <code>;</code> dentro de la misma celda. Si <code>categoria</code>/<code>coleccion</code> no
|
||||||
|
coinciden con una ya existente, se crean nuevas. Si <code>titulo</code> coincide con un producto ya
|
||||||
|
importado, no se duplica: se le agregan las fotos nuevas y se actualiza la descripción si la fila trae una.
|
||||||
|
Cada foto se descarga y se guarda en el almacenamiento propio de la tienda (no queda enlazada al sitio de
|
||||||
|
origen).
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-ui-fg-subtle">
|
||||||
|
<th className="w-10 py-2" />
|
||||||
|
<th className="py-2 font-medium">Fila</th>
|
||||||
|
<th className="py-2 font-medium">Producto</th>
|
||||||
|
<th className="py-2 font-medium">Detalle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((result) => (
|
||||||
|
<tr key={result.row} className="border-b last:border-0">
|
||||||
|
<td className="py-2">
|
||||||
|
{result.ok ? (
|
||||||
|
<CheckCircleSolid className="text-ui-tag-green-icon" />
|
||||||
|
) : (
|
||||||
|
<XCircleSolid className="text-ui-tag-red-icon" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">{result.row}</td>
|
||||||
|
<td className="py-2">{result.title}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{result.ok ? (
|
||||||
|
<Badge color="green">{result.action === "updated" ? "Actualizado (ya existía)" : "Importado"}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-rose-600">{result.error}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Importación",
|
||||||
|
icon: ArrowUpTray,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProductImportPanel;
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||||
|
import { Buildings } from "@medusajs/icons";
|
||||||
|
import { Badge, Button, Checkbox, Container, Heading, Input, Switch, Text, toast } from "@medusajs/ui";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { sdk } from "../../lib/sdk";
|
||||||
|
|
||||||
|
// Panel simplificado para alguien no tecnico (la esposa del dueno del
|
||||||
|
// negocio) -- una fila por producto (este catalogo es 1 variante por
|
||||||
|
// producto, confirmado en vivo), switch para activar seguimiento de
|
||||||
|
// stock y un input de cantidad que guarda solo. La vista nativa de
|
||||||
|
// Medusa Admin (Inventory) sigue disponible para el dueno, esto no la
|
||||||
|
// reemplaza, es un atajo.
|
||||||
|
|
||||||
|
const LOW_STOCK_THRESHOLD = 5;
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
productId: string;
|
||||||
|
variantId: string | null;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
sku: string | null;
|
||||||
|
managed: boolean;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRows(): Promise<Row[]> {
|
||||||
|
const data = await sdk.client.fetch<{ rows: Row[] }>("/admin/inventory-panel");
|
||||||
|
return data.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRow(row: { productId: string; variantId: string; managed?: boolean; quantity?: number }) {
|
||||||
|
await sdk.client.fetch("/admin/inventory-panel", { method: "post", body: row });
|
||||||
|
}
|
||||||
|
|
||||||
|
const InventoryPanel = () => {
|
||||||
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRows().then((data) => {
|
||||||
|
setRows(data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleSelected(productId: string, checked: boolean) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.add(productId);
|
||||||
|
else next.delete(productId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateManaged(row: Row, managed: boolean) {
|
||||||
|
if (!row.variantId) return;
|
||||||
|
setSavingId(row.productId);
|
||||||
|
try {
|
||||||
|
await saveRow({ productId: row.productId, variantId: row.variantId, managed });
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.productId === row.productId ? { ...r, managed } : r)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("No se pudo guardar", { description: row.title });
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateQuantity(row: Row, quantity: number) {
|
||||||
|
if (!row.variantId || Number.isNaN(quantity) || quantity < 0) return;
|
||||||
|
setSavingId(row.productId);
|
||||||
|
try {
|
||||||
|
await saveRow({ productId: row.productId, variantId: row.variantId, quantity });
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.productId === row.productId ? { ...r, quantity } : r)),
|
||||||
|
);
|
||||||
|
toast.success("Stock actualizado", { description: row.title });
|
||||||
|
} catch {
|
||||||
|
toast.error("No se pudo guardar", { description: row.title });
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkSetManaged(managed: boolean) {
|
||||||
|
const targets = rows.filter((r) => selected.has(r.productId) && r.variantId);
|
||||||
|
for (const row of targets) {
|
||||||
|
await updateManaged(row, managed);
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Inventario</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Activa el seguimiento de stock y ajusta la cantidad disponible por producto.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(true)}>
|
||||||
|
Activar seguimiento ({selected.size})
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="secondary" onClick={() => bulkSetManaged(false)}>
|
||||||
|
Desactivar seguimiento
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<Text className="text-ui-fg-subtle">Cargando…</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-ui-fg-subtle">
|
||||||
|
<th className="w-10 px-6 py-3" />
|
||||||
|
<th className="px-3 py-3 font-medium">Producto</th>
|
||||||
|
<th className="px-3 py-3 font-medium">SKU</th>
|
||||||
|
<th className="px-3 py-3 font-medium">Trackear stock</th>
|
||||||
|
<th className="px-3 py-3 font-medium">Cantidad disponible</th>
|
||||||
|
<th className="px-3 py-3 font-medium" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.productId} className="border-b last:border-0">
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(row.productId)}
|
||||||
|
onCheckedChange={(checked) => toggleSelected(row.productId, checked === true)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="flex items-center gap-3 px-3 py-3">
|
||||||
|
{row.thumbnail ? (
|
||||||
|
<img src={row.thumbnail} alt="" className="size-8 rounded object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="size-8 rounded bg-ui-bg-subtle" />
|
||||||
|
)}
|
||||||
|
<Text>{row.title}</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<Text className="text-ui-fg-subtle">{row.sku ?? "—"}</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<Switch
|
||||||
|
checked={row.managed}
|
||||||
|
disabled={savingId === row.productId}
|
||||||
|
onCheckedChange={(checked) => updateManaged(row, checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
defaultValue={row.quantity}
|
||||||
|
disabled={!row.managed || savingId === row.productId}
|
||||||
|
className="w-24"
|
||||||
|
onBlur={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
if (value !== row.quantity) updateQuantity(row, value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
{row.managed && row.quantity <= LOW_STOCK_THRESHOLD && (
|
||||||
|
<Badge color="red">Stock bajo</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Inventario",
|
||||||
|
icon: Buildings,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default InventoryPanel;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||||
|
import { Star } from "@medusajs/icons";
|
||||||
|
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { sdk } from "../../lib/sdk";
|
||||||
|
|
||||||
|
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
|
||||||
|
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
|
||||||
|
// recien se publican en la tienda cuando se aprueban aca.
|
||||||
|
|
||||||
|
type Status = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
type Review = {
|
||||||
|
id: string;
|
||||||
|
product_title: string;
|
||||||
|
author_name: string;
|
||||||
|
rating: number;
|
||||||
|
title: string | null;
|
||||||
|
content: string;
|
||||||
|
status: Status;
|
||||||
|
order_id: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: { status: Status; label: string }[] = [
|
||||||
|
{ status: "pending", label: "Pendientes" },
|
||||||
|
{ status: "approved", label: "Publicadas" },
|
||||||
|
{ status: "rejected", label: "Rechazadas" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
|
||||||
|
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
|
||||||
|
|
||||||
|
function stars(rating: number): string {
|
||||||
|
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewsPage = () => {
|
||||||
|
const [tab, setTab] = useState<Status>("pending");
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (status: Status) => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
|
||||||
|
setReviews(data.reviews);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(tab);
|
||||||
|
}, [load, tab]);
|
||||||
|
|
||||||
|
async function setStatus(review: Review, status: Status) {
|
||||||
|
setBusyId(review.id);
|
||||||
|
try {
|
||||||
|
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
|
||||||
|
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
|
||||||
|
await load(tab);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(review: Review) {
|
||||||
|
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
|
||||||
|
setBusyId(review.id);
|
||||||
|
try {
|
||||||
|
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
|
||||||
|
toast.success("Reseña borrada");
|
||||||
|
await load(tab);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Reseñas</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TABS.map((item) => (
|
||||||
|
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-8"><Text>Cargando…</Text></div>
|
||||||
|
) : reviews.length === 0 ? (
|
||||||
|
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
|
||||||
|
) : (
|
||||||
|
reviews.map((review) => (
|
||||||
|
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Text weight="plus">{review.product_title}</Text>
|
||||||
|
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
|
||||||
|
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
|
||||||
|
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
|
||||||
|
<Text size="small" className="mt-2 text-ui-fg-subtle">
|
||||||
|
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{review.status !== "approved" && (
|
||||||
|
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
|
||||||
|
)}
|
||||||
|
{review.status !== "rejected" && (
|
||||||
|
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
|
||||||
|
)}
|
||||||
|
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Reseñas",
|
||||||
|
icon: Star,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ReviewsPage;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||||
|
import {
|
||||||
|
createInventoryItemsWorkflow,
|
||||||
|
updateInventoryLevelsWorkflow,
|
||||||
|
updateProductVariantsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows";
|
||||||
|
import { attachInventoryItemToVariantWorkflow } from "../../../workflows/attach-inventory-item-to-variant";
|
||||||
|
|
||||||
|
// Panel simplificado de inventario (pensado para un usuario no tecnico,
|
||||||
|
// no reemplaza la vista nativa de Medusa Admin -- ver ADR/memoria del
|
||||||
|
// bug de Firefox Android, tema aparte). Un producto = una variante en
|
||||||
|
// este catalogo real (confirmado en vivo contra /store/products antes
|
||||||
|
// de asumirlo), asi que la tabla trabaja a nivel de producto.
|
||||||
|
|
||||||
|
const PRODUCT_FIELDS = [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"thumbnail",
|
||||||
|
"status",
|
||||||
|
"variants.id",
|
||||||
|
"variants.sku",
|
||||||
|
"variants.manage_inventory",
|
||||||
|
"variants.inventory_items.inventory_item_id",
|
||||||
|
"variants.inventory_items.inventory.location_levels.id",
|
||||||
|
"variants.inventory_items.inventory.location_levels.location_id",
|
||||||
|
"variants.inventory_items.inventory.location_levels.stocked_quantity",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function defaultLocationId(req: MedusaRequest): Promise<string | null> {
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const { data: locations = [] } = await query.graph({
|
||||||
|
entity: "stock_location",
|
||||||
|
fields: ["id"],
|
||||||
|
});
|
||||||
|
return locations[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const locationId = await defaultLocationId(req);
|
||||||
|
|
||||||
|
const { data: products = [] } = await query.graph({
|
||||||
|
entity: "product",
|
||||||
|
fields: PRODUCT_FIELDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = products.map((product) => {
|
||||||
|
const variant = product.variants?.[0];
|
||||||
|
const inventoryItem = variant?.inventory_items?.[0];
|
||||||
|
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||||
|
(candidate) => candidate?.location_id === locationId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
productId: product.id,
|
||||||
|
variantId: variant?.id ?? null,
|
||||||
|
title: product.title,
|
||||||
|
thumbnail: product.thumbnail,
|
||||||
|
status: product.status,
|
||||||
|
sku: variant?.sku ?? null,
|
||||||
|
managed: variant?.manage_inventory ?? false,
|
||||||
|
quantity: level?.stocked_quantity ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ rows, locationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelUpdateBody = {
|
||||||
|
productId: string;
|
||||||
|
variantId: string;
|
||||||
|
managed?: boolean;
|
||||||
|
quantity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: MedusaRequest<PanelUpdateBody>, res: MedusaResponse) {
|
||||||
|
const { productId, variantId, managed, quantity } = req.body;
|
||||||
|
if (!productId || !variantId) {
|
||||||
|
res.status(400).json({ error: "productId y variantId son obligatorios" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const locationId = await defaultLocationId(req);
|
||||||
|
if (!locationId) {
|
||||||
|
res.status(400).json({ error: "No hay una bodega (stock location) configurada" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: products = [] } = await query.graph({
|
||||||
|
entity: "product",
|
||||||
|
fields: PRODUCT_FIELDS,
|
||||||
|
filters: { id: productId },
|
||||||
|
});
|
||||||
|
const variant = products[0]?.variants?.find((v: { id: string }) => v.id === variantId);
|
||||||
|
const inventoryItem = variant?.inventory_items?.[0];
|
||||||
|
let inventoryItemId: string | undefined = inventoryItem?.inventory_item_id;
|
||||||
|
|
||||||
|
if (managed === true && !inventoryItemId) {
|
||||||
|
// Primera vez que se activa el seguimiento para esta variante -- no
|
||||||
|
// trae InventoryItem todavia (confirmado en vivo: togglear
|
||||||
|
// manage_inventory=true en una variante existente NO crea uno solo,
|
||||||
|
// a diferencia de crear un producto nuevo con manage_inventory
|
||||||
|
// desde el inicio). Se crea con el nivel de stock inicial y se
|
||||||
|
// vincula aparte.
|
||||||
|
const { result: items } = await createInventoryItemsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: variant?.sku ? undefined : products[0]?.title,
|
||||||
|
sku: variant?.sku ?? undefined,
|
||||||
|
location_levels: [{ location_id: locationId, stocked_quantity: quantity ?? 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
inventoryItemId = items[0]?.id;
|
||||||
|
if (inventoryItemId) {
|
||||||
|
await attachInventoryItemToVariantWorkflow(req.scope).run({
|
||||||
|
input: { inventoryItemId, variantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await updateProductVariantsWorkflow(req.scope).run({
|
||||||
|
input: { product_variants: [{ id: variantId, manage_inventory: true }] },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (typeof managed === "boolean") {
|
||||||
|
await updateProductVariantsWorkflow(req.scope).run({
|
||||||
|
input: { product_variants: [{ id: variantId, manage_inventory: managed }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof quantity === "number" && inventoryItemId) {
|
||||||
|
const level = inventoryItem?.inventory?.location_levels?.find(
|
||||||
|
(candidate) => candidate?.location_id === locationId,
|
||||||
|
);
|
||||||
|
await updateInventoryLevelsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
updates: [
|
||||||
|
{
|
||||||
|
id: level?.id,
|
||||||
|
inventory_item_id: inventoryItemId,
|
||||||
|
location_id: locationId,
|
||||||
|
stocked_quantity: quantity,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { Modules } from "@medusajs/framework/utils";
|
||||||
|
import type {
|
||||||
|
IFulfillmentModuleService,
|
||||||
|
IProductModuleService,
|
||||||
|
ISalesChannelModuleService,
|
||||||
|
IStockLocationService,
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
import {
|
||||||
|
createCollectionsWorkflow,
|
||||||
|
createInventoryItemsWorkflow,
|
||||||
|
createProductCategoriesWorkflow,
|
||||||
|
createProductsWorkflow,
|
||||||
|
updateProductsWorkflow,
|
||||||
|
uploadFilesWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows";
|
||||||
|
import { csvRowsToObjects, parseCsv } from "../../../lib/csv";
|
||||||
|
|
||||||
|
// Importador masivo de productos via CSV -- para alguien que compra
|
||||||
|
// producto fisico en Amazon/eBay/etc. y quiere cargarlo rapido a la
|
||||||
|
// tienda, no para dropshipping automatizado (ver conversacion: se evaluo
|
||||||
|
// y se descarto raspar catalogos de terceros sin autorizacion). Cada
|
||||||
|
// fila se procesa independiente (una foto rota o categoria mal escrita
|
||||||
|
// no tumba el resto del archivo).
|
||||||
|
//
|
||||||
|
// categoria/coleccion: si el nombre no matchea una categoria/coleccion
|
||||||
|
// ya existente (case-insensitive), se crea nueva -- antes se descartaba
|
||||||
|
// en silencio y el producto quedaba sin categoria/coleccion sin avisar
|
||||||
|
// (confirmado en vivo: "Hello Kitty & Friends" nunca se creo como
|
||||||
|
// coleccion). El handle de la nueva categoria/coleccion se genera con
|
||||||
|
// el mismo slugify() ASCII-safe de mas abajo, por la misma razon que el
|
||||||
|
// handle del producto: el default de Medusa no quita tildes/enies y
|
||||||
|
// rompe la ruta publica.
|
||||||
|
//
|
||||||
|
// Re-importar el mismo titulo (ej. para agregar mas fotos o actualizar
|
||||||
|
// la descripcion de un producto ya cargado) actualiza el producto
|
||||||
|
// existente en vez de crear uno nuevo -- antes cada re-importacion
|
||||||
|
// generaba un producto duplicado con handle distinto (confirmado en
|
||||||
|
// vivo con "Hello Kitty & Friends": misma fila re-subida para sumar
|
||||||
|
// fotos, dos productos con el mismo titulo en la tienda). El match es
|
||||||
|
// por titulo normalizado (case/espacios/guiones-comillas tipograficas,
|
||||||
|
// ver normalizeTitleForMatch), igual que categoria y coleccion; en un
|
||||||
|
// match, las fotos nuevas se agregan a las que ya tenia (no se
|
||||||
|
// reemplazan) y la descripcion se actualiza si la fila trae una. No
|
||||||
|
// toca precio/cantidad/categoria/coleccion del producto existente --
|
||||||
|
// eso se edita a mano en el admin si hace falta.
|
||||||
|
//
|
||||||
|
// El match era por titulo exacto (case-insensitive/trim) hasta que
|
||||||
|
// volvio a duplicar "Toy Story 5 - Woody": la fila reimportada traia
|
||||||
|
// un guion largo "–" (pegado desde Excel/Sheets, que autocorrige "-" a
|
||||||
|
// en-dash) en vez del guion normal "-" del producto original, asi que
|
||||||
|
// el string no calzaba y se creo un producto nuevo en vez de
|
||||||
|
// actualizar el existente (confirmado en vivo 2026-09-19). Ademas de
|
||||||
|
// guiones, se normalizan comillas tipograficas por la misma razon --
|
||||||
|
// cualquier CSV pegado desde un editor de texto enriquecido puede
|
||||||
|
// traerlas.
|
||||||
|
//
|
||||||
|
// Las fotos se descargan del origen y se re-alojan en el mismo
|
||||||
|
// MinIO/S3 que ya usa el resto del catalogo (uploadFilesWorkflow ->
|
||||||
|
// mismo File Module Provider de medusa-config.ts, ya corre en el NAS).
|
||||||
|
// El bucket (S3_BUCKET=ari-products) es plano -- el provider de S3 de
|
||||||
|
// Medusa descarta cualquier subcarpeta del filename (usa solo
|
||||||
|
// path.parse().name + ulid, confirmado leyendo
|
||||||
|
// @medusajs/file-s3/dist/services/s3-file.js), asi que no hay una
|
||||||
|
// carpeta separada real para lo importado -- queda mezclado con el
|
||||||
|
// resto de fotos del catalogo, mismo criterio de nombres unicos por
|
||||||
|
// ulid que ya usa todo el sitio.
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
||||||
|
const IMAGE_FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const OPTION_TITLE = "Presentación";
|
||||||
|
const DEFAULT_VARIANT_VALUE = "Estandar";
|
||||||
|
|
||||||
|
type ImportRow = {
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
precio: string;
|
||||||
|
categoria: string;
|
||||||
|
coleccion: string;
|
||||||
|
cantidad: string;
|
||||||
|
fotos: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowResult = {
|
||||||
|
row: number;
|
||||||
|
title: string;
|
||||||
|
ok: boolean;
|
||||||
|
action?: "created" | "updated";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Medusa genera el handle por defecto solo en minusculas + espacios a
|
||||||
|
// guiones, sin quitar tildes/enies -- confirmado en vivo: 3 productos
|
||||||
|
// importados con titulo acentuado (ej. "niñas", "Lógicos") quedaron con
|
||||||
|
// handle como "...niñas..." y daban 404 real al entrar por
|
||||||
|
// shop.cruzcloud.net (el tunel/ingress publico rompe rutas con UTF-8
|
||||||
|
// no-ASCII en el path, aunque el mismo request funciona perfecto pegandole
|
||||||
|
// directo al servicio dentro del cluster). Se genera el handle nosotros
|
||||||
|
// mismos, ASCII-only, en vez de dejarselo al default de Medusa. El sufijo
|
||||||
|
// corto evita colisiones entre titulos parecidos/repetidos sin necesidad
|
||||||
|
// de revisar unicidad antes de crear.
|
||||||
|
// Colapsa variantes de guion (-, en-dash, em-dash, minus) y comillas
|
||||||
|
// tipograficas a su forma ASCII antes de comparar titulos, para que un
|
||||||
|
// re-import pegado desde Excel/Sheets/Word (que autocorrigen esos
|
||||||
|
// caracteres) siga matcheando el producto ya existente. Ver nota mas
|
||||||
|
// arriba sobre "Toy Story 5 - Woody".
|
||||||
|
function normalizeTitleForMatch(title: string): string {
|
||||||
|
return title
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/[‐-―−]/g, "-")
|
||||||
|
.replace(/[‘’‛]/g, "'")
|
||||||
|
.replace(/[“”‟]/g, '"')
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorias/colecciones: ademas de lo anterior, ignora tildes y eñes
|
||||||
|
// ("Figuras de accion" == "Figuras de acción"). Desde 2026-09-27 los
|
||||||
|
// nombres reales en Medusa llevan tildes; sin esto, un CSV escrito sin
|
||||||
|
// tildes (o viejo) crearia una categoria DUPLICADA en vez de reusar la
|
||||||
|
// existente. Los titulos de producto siguen con normalizeTitleForMatch.
|
||||||
|
function normalizeNameForMatch(name: string): string {
|
||||||
|
return normalizeTitleForMatch(name).normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(title: string, uniquePart: string): string {
|
||||||
|
const base = title
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80)
|
||||||
|
.replace(/-+$/g, "");
|
||||||
|
return `${base || "producto"}-${uniquePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromContentType(contentType: string | null, url: string): string {
|
||||||
|
if (contentType?.includes("png")) return "png";
|
||||||
|
if (contentType?.includes("webp")) return "webp";
|
||||||
|
if (contentType?.includes("gif")) return "gif";
|
||||||
|
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) return "jpg";
|
||||||
|
const match = url.match(/\.(jpe?g|png|webp|gif)(?:\?|$)/i);
|
||||||
|
return match ? match[1].toLowerCase().replace("jpeg", "jpg") : "jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImageAsBase64(url: string): Promise<{ content: string; mimeType: string; extension: string }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`No se pudo descargar la imagen (${response.status})`);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`Imagen supera ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
|
||||||
|
}
|
||||||
|
const extension = extensionFromContentType(contentType, url);
|
||||||
|
return {
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: contentType?.split(";")[0] ?? `image/${extension === "jpg" ? "jpeg" : extension}`,
|
||||||
|
extension,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Plantilla descargable -- misma fuente de verdad que valida el POST,
|
||||||
|
// asi que las columnas nunca quedan desincronizadas entre lo que se
|
||||||
|
// ofrece descargar y lo que realmente se procesa.
|
||||||
|
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
|
||||||
|
const example =
|
||||||
|
'"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de acción","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
|
||||||
|
const csv = `${header}\n${example}\n`;
|
||||||
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
|
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaResponse) {
|
||||||
|
const csvText = req.body?.csv;
|
||||||
|
if (!csvText || typeof csvText !== "string") {
|
||||||
|
res.status(400).json({ error: "Falta el contenido CSV" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = csvRowsToObjects(parseCsv(csvText)) as unknown as ImportRow[];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
res.status(400).json({ error: "El archivo no tiene filas de datos" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockLocationService = req.scope.resolve<IStockLocationService>(Modules.STOCK_LOCATION);
|
||||||
|
const fulfillmentService = req.scope.resolve<IFulfillmentModuleService>(Modules.FULFILLMENT);
|
||||||
|
const salesChannelService = req.scope.resolve<ISalesChannelModuleService>(Modules.SALES_CHANNEL);
|
||||||
|
const productService = req.scope.resolve<IProductModuleService>(Modules.PRODUCT);
|
||||||
|
|
||||||
|
const [locations, shippingProfiles, salesChannels, categories, collections, existingProducts] = await Promise.all([
|
||||||
|
stockLocationService.listStockLocations({}),
|
||||||
|
fulfillmentService.listShippingProfiles({}),
|
||||||
|
salesChannelService.listSalesChannels({}),
|
||||||
|
// select explicito -- sin el, name/title llegaban undefined (el
|
||||||
|
// default de estos module services no siempre trae todos los
|
||||||
|
// escalares, a diferencia de la capa HTTP del Admin API, confirmado
|
||||||
|
// en vivo: la ruta /admin/product-categories con fields=id,name si
|
||||||
|
// los trae completos).
|
||||||
|
productService.listProductCategories({}, { select: ["id", "name"] }),
|
||||||
|
productService.listProductCollections({}, { select: ["id", "title"] }),
|
||||||
|
productService.listProducts({}, { select: ["id", "title"], relations: ["images"] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const locationId = locations[0]?.id;
|
||||||
|
const shippingProfileId = shippingProfiles.find((p) => p.type === "default")?.id ?? shippingProfiles[0]?.id;
|
||||||
|
const salesChannelId = salesChannels[0]?.id;
|
||||||
|
|
||||||
|
if (!locationId || !shippingProfileId || !salesChannelId) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Faltan datos base de la tienda (bodega, perfil de envío o canal de venta)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryByName = new Map(
|
||||||
|
categories.filter((c) => c.name).map((c) => [normalizeNameForMatch(c.name), c.id]),
|
||||||
|
);
|
||||||
|
const collectionByName = new Map(
|
||||||
|
collections.filter((c) => c.title).map((c) => [normalizeNameForMatch(c.title), c.id]),
|
||||||
|
);
|
||||||
|
const productByTitle = new Map(
|
||||||
|
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: RowResult[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < rows.length; index++) {
|
||||||
|
const rowNumber = index + 2; // +1 header, +1 base 1
|
||||||
|
const row = rows[index];
|
||||||
|
const title = row.titulo?.trim();
|
||||||
|
try {
|
||||||
|
if (!title) throw new Error("Falta el título");
|
||||||
|
const price = Number(row.precio);
|
||||||
|
if (!Number.isFinite(price) || price <= 0) throw new Error("Precio inválido");
|
||||||
|
|
||||||
|
const imageUrls = (row.fotos ?? "")
|
||||||
|
.split(";")
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (imageUrls.length === 0) throw new Error("Falta al menos una foto");
|
||||||
|
|
||||||
|
const downloaded = await Promise.all(imageUrls.map((url) => downloadImageAsBase64(url)));
|
||||||
|
const { result: uploadedFiles } = await uploadFilesWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
files: downloaded.map((file, i) => ({
|
||||||
|
filename: `${Date.now()}-${index}-${i}.${file.extension}`,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
content: file.content,
|
||||||
|
access: "public" as const,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingProduct = productByTitle.get(normalizeTitleForMatch(title));
|
||||||
|
if (existingProduct) {
|
||||||
|
await updateProductsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: existingProduct.id,
|
||||||
|
description: row.descripcion?.trim() || undefined,
|
||||||
|
images: [...(existingProduct.images ?? []).map((image) => ({ url: image.url })), ...uploadedFiles.map((f) => ({ url: f.url }))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push({ row: rowNumber, title, ok: true, action: "updated" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Number(row.cantidad);
|
||||||
|
const { result: inventoryItems } = await createInventoryItemsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
location_levels: [
|
||||||
|
{ location_id: locationId, stocked_quantity: Number.isFinite(quantity) && quantity >= 0 ? quantity : 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const inventoryItemId = inventoryItems[0]?.id;
|
||||||
|
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
|
||||||
|
|
||||||
|
const categoryName = row.categoria?.trim();
|
||||||
|
let categoryId = categoryName ? categoryByName.get(normalizeNameForMatch(categoryName)) : undefined;
|
||||||
|
if (categoryName && !categoryId) {
|
||||||
|
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
product_categories: [
|
||||||
|
{ name: categoryName, handle: slugify(categoryName, `${Date.now().toString(36)}${index}`) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryId = createdCategories[0].id;
|
||||||
|
categoryByName.set(normalizeNameForMatch(categoryName), categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionName = row.coleccion?.trim();
|
||||||
|
let collectionId = collectionName ? collectionByName.get(normalizeNameForMatch(collectionName)) : undefined;
|
||||||
|
if (collectionName && !collectionId) {
|
||||||
|
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
collections: [
|
||||||
|
{ title: collectionName, handle: slugify(collectionName, `${Date.now().toString(36)}${index}`) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collectionId = createdCollections[0].id;
|
||||||
|
collectionByName.set(normalizeNameForMatch(collectionName), collectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createProductsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
handle: slugify(title, `${Date.now().toString(36)}${index}`),
|
||||||
|
description: row.descripcion || undefined,
|
||||||
|
status: "published",
|
||||||
|
thumbnail: uploadedFiles[0]?.url,
|
||||||
|
images: uploadedFiles.map((f) => ({ url: f.url })),
|
||||||
|
category_ids: categoryId ? [categoryId] : undefined,
|
||||||
|
collection_id: collectionId,
|
||||||
|
shipping_profile_id: shippingProfileId,
|
||||||
|
sales_channels: [{ id: salesChannelId }],
|
||||||
|
options: [{ title: OPTION_TITLE, values: [DEFAULT_VARIANT_VALUE] }],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
title: DEFAULT_VARIANT_VALUE,
|
||||||
|
options: { [OPTION_TITLE]: DEFAULT_VARIANT_VALUE },
|
||||||
|
manage_inventory: true,
|
||||||
|
inventory_items: [{ inventory_item_id: inventoryItemId }],
|
||||||
|
prices: [{ amount: price, currency_code: "cop" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ row: rowNumber, title, ok: true, action: "created" });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ row: rowNumber, title: title || `Fila ${rowNumber}`, ok: false, error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { MedusaError } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
const STATUSES = ["pending", "approved", "rejected"] as const;
|
||||||
|
|
||||||
|
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
|
||||||
|
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
|
||||||
|
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
|
||||||
|
const status = req.body?.status;
|
||||||
|
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
|
||||||
|
}
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
|
||||||
|
res.json({ review });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
await reviews.deleteProductReviews(req.params.id);
|
||||||
|
res.json({ id: req.params.id, deleted: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /admin/reviews?status=pending -- cola de moderacion para la
|
||||||
|
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
|
||||||
|
order: { created_at: "DESC" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const productIds = [...new Set(list.map((review) => review.product_id))];
|
||||||
|
const titles: Record<string, string> = {};
|
||||||
|
if (productIds.length) {
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
|
||||||
|
for (const product of products) titles[product.id] = product.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineMiddlewares } from "@medusajs/medusa"
|
import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
|
||||||
|
|
||||||
// GET /store/products descarta en silencio la relacion `categories`
|
// GET /store/products descarta en silencio la relacion `categories`
|
||||||
// aunque `/admin/products` si la expone -- confirmado en vivo en la
|
// aunque `/admin/products` si la expone -- confirmado en vivo en la
|
||||||
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
|
|||||||
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
|
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
|
||||||
export default defineMiddlewares({
|
export default defineMiddlewares({
|
||||||
routes: [
|
routes: [
|
||||||
|
// Reseñas: crear una y ver las propias exige cliente autenticado --
|
||||||
|
// la verificacion de compra usa auth_context.actor_id (ver
|
||||||
|
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
|
||||||
|
// siguen publicos.
|
||||||
|
{
|
||||||
|
matcher: "/store/reviews",
|
||||||
|
method: ["POST"],
|
||||||
|
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: "/store/reviews/me",
|
||||||
|
method: ["GET"],
|
||||||
|
middlewares: [authenticate("customer", ["session", "bearer"])],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matcher: "/store/products",
|
matcher: "/store/products",
|
||||||
middlewares: [
|
middlewares: [
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { createEpaycoClient, type EpaycoEnv } from "../../../../lib/epayco";
|
||||||
|
|
||||||
|
// Lista de bancos PSE -- no hay forma de pedirle esto al modulo de pago
|
||||||
|
// custom directamente (AbstractPaymentProvider no expone sus metodos
|
||||||
|
// internos fuera del contrato de pago), asi que esta ruta llama a
|
||||||
|
// ePayco con las mismas credenciales de medusa-config.ts. Cache en
|
||||||
|
// memoria (sin Redis/cron), mismo criterio de simplicidad que ya usa la
|
||||||
|
// seccion de Meilisearch en medusa-config.ts.
|
||||||
|
//
|
||||||
|
// El shape exacto de la respuesta de bank.getBanks() no quedo verificado
|
||||||
|
// contra el sandbox real en esta sesion -- se prueban los nombres de
|
||||||
|
// campo mas probables (bankCode/bankName y variantes) y se degrada a
|
||||||
|
// lista vacia si no matchea nada, en vez de romper el checkout entero.
|
||||||
|
//
|
||||||
|
// Verificado en vivo despues (navegador real, Playwright): el sandbox
|
||||||
|
// real de ePayco incluye un primer elemento placeholder
|
||||||
|
// (bankCode "0", "A continuación seleccione su banco...") mezclado con
|
||||||
|
// los bancos reales -- ningun banco PSE real tiene codigo "0", asi que
|
||||||
|
// se filtra explicitamente en vez de mostrarlo como opcion seleccionable.
|
||||||
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
let cache: { banks: Array<{ pseCode: string; description: string }>; fetchedAt: number } | null = null;
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
|
||||||
|
res.json({ banks: cache.banks });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createEpaycoClient({
|
||||||
|
publicKey: process.env.EPAYCO_PUBLIC_KEY ?? "",
|
||||||
|
privateKey: process.env.EPAYCO_PRIVATE_KEY ?? "",
|
||||||
|
pCustIdCliente: process.env.EPAYCO_P_CUST_ID_CLIENTE ?? "",
|
||||||
|
pKey: process.env.EPAYCO_P_KEY ?? "",
|
||||||
|
env: (process.env.EPAYCO_ENV as EpaycoEnv) || "sandbox",
|
||||||
|
});
|
||||||
|
const response = await client.bank.getBanks();
|
||||||
|
const rawBanks = (response.data ?? response.banks ?? response) as unknown;
|
||||||
|
const list = Array.isArray(rawBanks) ? rawBanks : [];
|
||||||
|
|
||||||
|
const banks = list
|
||||||
|
.map((bank) => {
|
||||||
|
const record = bank as Record<string, unknown>;
|
||||||
|
const pseCode = record.bankCode ?? record.bank_code ?? record.codigo ?? record.id;
|
||||||
|
const description = record.bankName ?? record.bank_name ?? record.nombre ?? record.name ?? record.description;
|
||||||
|
return typeof pseCode === "string" || typeof pseCode === "number"
|
||||||
|
? { pseCode: String(pseCode), description: String(description ?? pseCode) }
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((bank): bank is { pseCode: string; description: string } => bank !== null && bank.pseCode !== "0");
|
||||||
|
|
||||||
|
cache = { banks, fetchedAt: Date.now() };
|
||||||
|
res.json({ banks });
|
||||||
|
} catch (error) {
|
||||||
|
res.json({ banks: cache?.banks ?? [], error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
|
||||||
|
// estado), para que "Mis pedidos" sepa que productos ya califico y en
|
||||||
|
// que estado de moderacion quedo cada una.
|
||||||
|
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
|
||||||
|
res.json({
|
||||||
|
reviews: mine.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
product_id: review.product_id,
|
||||||
|
order_id: review.order_id,
|
||||||
|
rating: review.rating,
|
||||||
|
title: review.title,
|
||||||
|
content: review.content,
|
||||||
|
status: review.status,
|
||||||
|
created_at: review.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
|
||||||
|
// mas promedio y cantidad. Publica (solo publishable key).
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
|
||||||
|
if (!productId) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
|
||||||
|
}
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const list = await reviews.listProductReviews(
|
||||||
|
{ product_id: productId, status: "approved" },
|
||||||
|
{ order: { created_at: "DESC" }, take: 50 },
|
||||||
|
);
|
||||||
|
const count = list.length;
|
||||||
|
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
|
||||||
|
res.json({
|
||||||
|
reviews: list.map((review) => ({
|
||||||
|
id: review.id,
|
||||||
|
author_name: review.author_name,
|
||||||
|
rating: review.rating,
|
||||||
|
title: review.title,
|
||||||
|
content: review.content,
|
||||||
|
created_at: review.created_at,
|
||||||
|
verified_purchase: true,
|
||||||
|
})),
|
||||||
|
average: Math.round(average * 10) / 10,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReviewBody = {
|
||||||
|
product_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
rating?: number;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
|
||||||
|
// si el pedido indicado es SUYO, no esta cancelado y contiene el
|
||||||
|
// producto: eso es lo que respalda el sello "Compra verificada". Nace
|
||||||
|
// "pending" hasta que el dueño la apruebe en el admin.
|
||||||
|
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
|
||||||
|
const customerId = req.auth_context.actor_id;
|
||||||
|
const { product_id, order_id, title } = req.body ?? {};
|
||||||
|
const rating = Number(req.body?.rating);
|
||||||
|
const content = (req.body?.content ?? "").trim();
|
||||||
|
|
||||||
|
if (!product_id || !order_id) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
|
||||||
|
}
|
||||||
|
if (content.length < 10 || content.length > 2000) {
|
||||||
|
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
fields: ["id", "customer_id", "status", "items.product_id"],
|
||||||
|
filters: { id: order_id },
|
||||||
|
});
|
||||||
|
const order = orders[0];
|
||||||
|
const purchased =
|
||||||
|
order &&
|
||||||
|
order.customer_id === customerId &&
|
||||||
|
order.status !== "canceled" &&
|
||||||
|
(order.items ?? []).some((item) => item?.product_id === product_id);
|
||||||
|
if (!purchased) {
|
||||||
|
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
fields: ["first_name", "last_name"],
|
||||||
|
filters: { id: customerId },
|
||||||
|
});
|
||||||
|
const customer = customers[0];
|
||||||
|
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
|
||||||
|
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
|
||||||
|
|
||||||
|
const review = await reviews.createProductReviews({
|
||||||
|
product_id,
|
||||||
|
order_id,
|
||||||
|
customer_id: customerId,
|
||||||
|
author_name: authorName,
|
||||||
|
rating,
|
||||||
|
title: title?.trim() || null,
|
||||||
|
content,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ review: { id: review.id, status: review.status } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
|
||||||
|
import type ProductReviewModuleService from "../../../../modules/product-review/service";
|
||||||
|
|
||||||
|
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
|
||||||
|
// por producto, para las estrellas de las tarjetas del listado (una sola
|
||||||
|
// llamada para todo el catalogo en vez de una por tarjeta).
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
|
||||||
|
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
|
||||||
|
const summary: Record<string, { average: number; count: number }> = {};
|
||||||
|
for (const review of approved) {
|
||||||
|
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
|
||||||
|
entry.average += review.rating;
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
for (const entry of Object.values(summary)) {
|
||||||
|
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
|
||||||
|
}
|
||||||
|
res.json({ summary });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Parser CSV minimo (RFC4180: campos entre comillas, comillas escapadas
|
||||||
|
// como "", comas y saltos de linea dentro de campos citados) -- se evita
|
||||||
|
// sumar una dependencia nueva (papaparse/csv-parse) para un formato que
|
||||||
|
// nosotros mismos controlamos via la plantilla descargable.
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
let i = 0;
|
||||||
|
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
|
while (i < normalized.length) {
|
||||||
|
const char = normalized[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (normalized[i + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inQuotes = false;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
field += char;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ",") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "\n") {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
field += char;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (field.length > 0 || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows.filter((r) => r.some((cell) => cell.trim().length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvRowsToObjects(rows: string[][]): Record<string, string>[] {
|
||||||
|
const [header, ...body] = rows;
|
||||||
|
if (!header) return [];
|
||||||
|
const keys = header.map((h) => h.trim().toLowerCase());
|
||||||
|
return body.map((cols) => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
keys.forEach((key, index) => {
|
||||||
|
obj[key] = (cols[index] ?? "").trim();
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones
|
||||||
|
// del checkout): cotización y generación de guía real por peso/volumen,
|
||||||
|
// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en
|
||||||
|
// vivo contra la cuenta real del usuario (apiDoc real,
|
||||||
|
// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no
|
||||||
|
// renderiza sin JS, pero ese archivo generado por apidoc trae la spec
|
||||||
|
// completa) y una llamada de prueba real que devolvió 200 con tarifas
|
||||||
|
// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC.
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.envioclickpro.com.co/api/v2";
|
||||||
|
|
||||||
|
export interface EnvioClickCredentials {
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickPackage {
|
||||||
|
weight: number;
|
||||||
|
height: number;
|
||||||
|
length: number;
|
||||||
|
// "widht" es un typo real de la API (confirmado en el body de
|
||||||
|
// /shipment de la doc oficial), no un error nuestro -- solo se usa acá,
|
||||||
|
// en el request de /quotation el campo si se llama "width" (confirmado
|
||||||
|
// tambien contra la doc real, son dos endpoints con esa inconsistencia).
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickRate {
|
||||||
|
idRate: number;
|
||||||
|
idProduct: number;
|
||||||
|
product: string;
|
||||||
|
idCarrier: number;
|
||||||
|
carrier: string;
|
||||||
|
flete: number;
|
||||||
|
minimumInsurance: number;
|
||||||
|
extraInsurance: number;
|
||||||
|
deliveryDays: number;
|
||||||
|
cod: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickAddress {
|
||||||
|
daneCode: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function envioclickRequest(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Header real confirmado contra la doc oficial y una llamada en
|
||||||
|
// vivo -- "Authorization" a secas (sin "Bearer"), NO
|
||||||
|
// "AuthorizationKey" como usaba el plugin de WordPress de
|
||||||
|
// referencia (ese era para la API v1, desactualizada).
|
||||||
|
Authorization: credentials.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let json: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (json.status !== "OK") {
|
||||||
|
throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo 1 paquete por cotizacion (limite real documentado y confirmado:
|
||||||
|
// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete).
|
||||||
|
export async function quoteShipment(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
params: {
|
||||||
|
pkg: EnvioClickPackage;
|
||||||
|
description: string;
|
||||||
|
contentValue: number;
|
||||||
|
origin: EnvioClickAddress;
|
||||||
|
destination: EnvioClickAddress;
|
||||||
|
},
|
||||||
|
): Promise<EnvioClickRate[]> {
|
||||||
|
const json = await envioclickRequest(credentials, "/quotation", {
|
||||||
|
packages: [params.pkg],
|
||||||
|
description: params.description,
|
||||||
|
contentValue: params.contentValue,
|
||||||
|
origin: params.origin,
|
||||||
|
destination: params.destination,
|
||||||
|
});
|
||||||
|
const data = json.data as Record<string, unknown>;
|
||||||
|
return (data.rates as EnvioClickRate[]) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvioClickPerson {
|
||||||
|
company: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
suburb: string;
|
||||||
|
crossStreet: string;
|
||||||
|
reference: string;
|
||||||
|
daneCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuideResult {
|
||||||
|
guide: string;
|
||||||
|
url: string;
|
||||||
|
tracker: string;
|
||||||
|
idOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuide(
|
||||||
|
credentials: EnvioClickCredentials,
|
||||||
|
sandbox: boolean,
|
||||||
|
params: {
|
||||||
|
idRate: number;
|
||||||
|
myShipmentReference: string;
|
||||||
|
externalOrderId: string;
|
||||||
|
pkg: EnvioClickPackage;
|
||||||
|
description: string;
|
||||||
|
contentValue: number;
|
||||||
|
origin: EnvioClickPerson;
|
||||||
|
destination: EnvioClickPerson;
|
||||||
|
},
|
||||||
|
): Promise<CreateGuideResult> {
|
||||||
|
// /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la
|
||||||
|
// cotizacion siempre usa tarifas reales de las transportadoras, no
|
||||||
|
// tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a
|
||||||
|
// la URL de producción para que puedas generar guías reales").
|
||||||
|
const path = sandbox ? "/shipment_sandbox" : "/shipment";
|
||||||
|
const json = await envioclickRequest(credentials, path, {
|
||||||
|
idRate: params.idRate,
|
||||||
|
myShipmentReference: params.myShipmentReference,
|
||||||
|
external_order_id: params.externalOrderId,
|
||||||
|
requestPickup: false,
|
||||||
|
pickupDate: new Date().toISOString().slice(0, 10),
|
||||||
|
insurance: false,
|
||||||
|
description: params.description,
|
||||||
|
contentValue: params.contentValue,
|
||||||
|
packages: [
|
||||||
|
{
|
||||||
|
weight: params.pkg.weight,
|
||||||
|
height: params.pkg.height,
|
||||||
|
length: params.pkg.length,
|
||||||
|
widht: params.pkg.width,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
origin: params.origin,
|
||||||
|
destination: params.destination,
|
||||||
|
});
|
||||||
|
const data = json.data as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
guide: String(data.guide ?? ""),
|
||||||
|
url: String(data.url ?? ""),
|
||||||
|
tracker: String(data.tracker ?? ""),
|
||||||
|
idOrder: Number(data.idOrder ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hasta 50 ordenes por llamada (limite real documentado).
|
||||||
|
export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise<void> {
|
||||||
|
await envioclickRequest(credentials, "cancellation/batch/order", { idOrders });
|
||||||
|
}
|
||||||
|
|
||||||
|
// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio,
|
||||||
|
// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa
|
||||||
|
// lib/data/colombia.ts del storefront, confirmado contra su propio
|
||||||
|
// comentario) con el padding real que espera EnvioClick (8 caracteres,
|
||||||
|
// confirmado contra su propio ejemplo real: "11001000" para Bogota =
|
||||||
|
// "11001" + "000").
|
||||||
|
export function toEnvioClickDaneCode(divipolaCityId: number): string {
|
||||||
|
return `${String(divipolaCityId).padStart(5, "0")}000`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
// ePayco (100% de Davivienda desde 2024, ver ADR 0009 -- tercer proveedor
|
||||||
|
// evaluado tras PayU LatAm/Rapyd, elegido por comision PSE mas baja para
|
||||||
|
// bajo volumen y tarifa preferencial con cuenta Davivienda, que el
|
||||||
|
// usuario ya tiene). A diferencia de PayU/Rapyd, no llamamos la API REST
|
||||||
|
// cruda nosotros: usamos el SDK oficial `epayco-sdk-node` (mantenido por
|
||||||
|
// github.com/epayco, ultimo publish reciente) porque su protocolo real
|
||||||
|
// -- leido del codigo fuente, no de la doc -- es bastante mas raro de lo
|
||||||
|
// que parece en los ejemplos: cada campo del payload se cifra con
|
||||||
|
// AES-CBC usando la private key como llave (IV fijo en cero) ANTES de
|
||||||
|
// mandarse a secure.payco.co, ademas de un login previo a
|
||||||
|
// api.secure.payco.co/v1/auth/login que da un bearer token que tambien
|
||||||
|
// va en la request. Reimplementar esto a mano tiene mucho mas riesgo de
|
||||||
|
// error que delegarlo al SDK del propio proveedor.
|
||||||
|
export type EpaycoEnv = "sandbox" | "production";
|
||||||
|
|
||||||
|
export interface EpaycoCredentials {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
pCustIdCliente: string;
|
||||||
|
pKey: string;
|
||||||
|
env: EpaycoEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin tipos de TypeScript en el paquete (confirmado: package.json no
|
||||||
|
// declara "types") -- se tipa manualmente el subset que usamos.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const EpaycoSdk = require("epayco-sdk-node");
|
||||||
|
|
||||||
|
export interface EpaycoClient {
|
||||||
|
bank: {
|
||||||
|
create(options: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||||
|
get(transactionId: string): Promise<Record<string, unknown>>;
|
||||||
|
getBanks(): Promise<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEpaycoClient(credentials: EpaycoCredentials): EpaycoClient {
|
||||||
|
return EpaycoSdk({
|
||||||
|
apiKey: credentials.publicKey,
|
||||||
|
privateKey: credentials.privateKey,
|
||||||
|
lang: "ES",
|
||||||
|
test: credentials.env === "sandbox",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firma del webhook de confirmacion (url_confirmation) -- formula
|
||||||
|
// verificada contra la documentacion real de ePayco:
|
||||||
|
// sha256(p_cust_id_cliente^p_key^x_ref_payco^x_transaction_id^x_amount^x_currency_code).
|
||||||
|
// p_cust_id_cliente/p_key son valores propios del dashboard de ePayco
|
||||||
|
// (Configuracion > Llaves secretas), distintos de publicKey/privateKey
|
||||||
|
// -- confirmar contra el dashboard real antes de la primera prueba.
|
||||||
|
// Falla cerrado: cualquier mismatch devuelve false, nunca se traga la
|
||||||
|
// excepcion.
|
||||||
|
export function verifyWebhookSignature(params: {
|
||||||
|
pCustIdCliente: string;
|
||||||
|
pKey: string;
|
||||||
|
refPayco: string;
|
||||||
|
transactionId: string;
|
||||||
|
amount: string;
|
||||||
|
currencyCode: string;
|
||||||
|
signature: string;
|
||||||
|
}): boolean {
|
||||||
|
const raw = `${params.pCustIdCliente}^${params.pKey}^${params.refPayco}^${params.transactionId}^${params.amount}^${params.currencyCode}`;
|
||||||
|
const expected = createHash("sha256").update(raw).digest("hex");
|
||||||
|
return expected.toLowerCase() === params.signature.toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
|
||||||
|
import EnvioClickFulfillmentService from "./service";
|
||||||
|
|
||||||
|
export default ModuleProvider(Modules.FULFILLMENT, {
|
||||||
|
services: [EnvioClickFulfillmentService],
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils";
|
||||||
|
import type {
|
||||||
|
CalculatedShippingOptionPrice,
|
||||||
|
CalculateShippingOptionPriceContext,
|
||||||
|
CalculateShippingOptionPriceDTO,
|
||||||
|
CreateFulfillmentResult,
|
||||||
|
CreateShippingOptionDTO,
|
||||||
|
FulfillmentDTO,
|
||||||
|
FulfillmentItemDTO,
|
||||||
|
FulfillmentOption,
|
||||||
|
FulfillmentOrderDTO,
|
||||||
|
Logger,
|
||||||
|
ValidateFulfillmentDataContext,
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick";
|
||||||
|
import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types";
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
logger: Logger;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contexto minimo compartido entre calculatePrice/createFulfillment --
|
||||||
|
// ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado
|
||||||
|
// como metadata en la direccion por el checkout -- ver ADR 0009 y
|
||||||
|
// checkout-client.tsx del storefront) y el paquete agregado del pedido.
|
||||||
|
interface AddressLike {
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
address_1?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destinationDaneCode(address: AddressLike | null | undefined): string | null {
|
||||||
|
const cityId = address?.metadata?.cityId;
|
||||||
|
return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por
|
||||||
|
// cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun
|
||||||
|
// item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver
|
||||||
|
// plan de implementacion), se usa el paquete de fallback conservador
|
||||||
|
// completo en vez de mezclar datos reales con inventados a medias.
|
||||||
|
function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) {
|
||||||
|
if (items.length === 0) return { ...DEFAULT_PACKAGE };
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const weight = item.variant?.weight;
|
||||||
|
if (!weight) return { ...DEFAULT_PACKAGE };
|
||||||
|
totalWeight += weight * item.quantity;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_PACKAGE, weight: totalWeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService {
|
||||||
|
static identifier = "envioclick";
|
||||||
|
|
||||||
|
protected logger_: Logger;
|
||||||
|
protected options_: EnvioClickOptions;
|
||||||
|
|
||||||
|
constructor(container: InjectedDependencies, options: EnvioClickOptions) {
|
||||||
|
super();
|
||||||
|
this.logger_ = container.logger;
|
||||||
|
this.options_ = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateOptions(options: Record<string, unknown>): void {
|
||||||
|
for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) {
|
||||||
|
if (!options[key]) {
|
||||||
|
throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
|
||||||
|
// Una sola opcion: siempre toma la tarifa mas barata entre las
|
||||||
|
// transportadoras que cotizan, mismo criterio que el plugin oficial
|
||||||
|
// de WooCommerce de referencia ("toma el precio mas economico").
|
||||||
|
return [{ id: "standard", name: "Envío nacional (calculado)" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateFulfillmentData(
|
||||||
|
optionData: Record<string, unknown>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
_context: ValidateFulfillmentDataContext,
|
||||||
|
): Promise<any> {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se invoca en vivo durante el checkout cada vez que se piden/refrescan
|
||||||
|
// las shipping options del carrito. Medusa advierte explicitamente que
|
||||||
|
// un error acá bloquea el checkout entero -- por eso NUNCA se deja
|
||||||
|
// propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA,
|
||||||
|
// EnvioClick caido) cae al precio fijo que ya se usaba antes de esta
|
||||||
|
// integracion, para no romper la compra.
|
||||||
|
async calculatePrice(
|
||||||
|
optionData: CalculateShippingOptionPriceDTO["optionData"],
|
||||||
|
_data: CalculateShippingOptionPriceDTO["data"],
|
||||||
|
context: CalculateShippingOptionPriceContext,
|
||||||
|
): Promise<CalculatedShippingOptionPrice> {
|
||||||
|
const fallback: CalculatedShippingOptionPrice = {
|
||||||
|
calculated_amount: FALLBACK_PRICE_COP,
|
||||||
|
is_calculated_price_tax_inclusive: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const address = context.shipping_address as AddressLike | undefined;
|
||||||
|
const daneCode = destinationDaneCode(address);
|
||||||
|
if (!daneCode) {
|
||||||
|
this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
|
||||||
|
const pkg = aggregatePackage(items);
|
||||||
|
|
||||||
|
const rates = await quoteShipment(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
{
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
|
||||||
|
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rates.length === 0) return fallback;
|
||||||
|
|
||||||
|
const cheapest = rates.reduce((min, rate) =>
|
||||||
|
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculated_amount: cheapest.flete + cheapest.minimumInsurance,
|
||||||
|
is_calculated_price_tax_inclusive: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A diferencia de calculatePrice, acá SÍ se deja propagar el error --
|
||||||
|
// el idRate de la cotización mostrada en checkout no se persiste en
|
||||||
|
// ningún lado (CalculatedShippingOptionPrice no tiene un campo `data`
|
||||||
|
// para eso, confirmado leyendo el tipo real), así que se vuelve a
|
||||||
|
// cotizar en el momento de generar la guía (más correcto igual: el
|
||||||
|
// fulfillment puede pasar horas/días después del checkout, un idRate
|
||||||
|
// viejo podría estar vencido) y se reserva con la tarifa más barata de
|
||||||
|
// esa cotización fresca.
|
||||||
|
async createFulfillment(
|
||||||
|
_data: Record<string, unknown>,
|
||||||
|
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
|
||||||
|
order: Partial<FulfillmentOrderDTO> | undefined,
|
||||||
|
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
|
||||||
|
): Promise<CreateFulfillmentResult> {
|
||||||
|
const address = order?.shipping_address as AddressLike | undefined;
|
||||||
|
const daneCode = destinationDaneCode(address);
|
||||||
|
if (!daneCode) {
|
||||||
|
throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
|
||||||
|
const pkg = aggregatePackage(items);
|
||||||
|
|
||||||
|
const rates = await quoteShipment(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
{
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
|
||||||
|
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (rates.length === 0) {
|
||||||
|
throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía.");
|
||||||
|
}
|
||||||
|
const cheapest = rates.reduce((min, rate) =>
|
||||||
|
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
|
||||||
|
);
|
||||||
|
|
||||||
|
const guide = await createGuide(
|
||||||
|
{ apiKey: this.options_.apiKey },
|
||||||
|
this.options_.sandbox,
|
||||||
|
{
|
||||||
|
idRate: cheapest.idRate,
|
||||||
|
myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`,
|
||||||
|
externalOrderId: String(order?.id ?? ""),
|
||||||
|
pkg,
|
||||||
|
description: "Pedido ARI Shopping",
|
||||||
|
contentValue: 50000,
|
||||||
|
origin: {
|
||||||
|
company: this.options_.originCompany,
|
||||||
|
firstName: this.options_.originFirstName,
|
||||||
|
lastName: this.options_.originLastName,
|
||||||
|
email: this.options_.originEmail,
|
||||||
|
phone: this.options_.originPhone,
|
||||||
|
address: this.options_.originAddress,
|
||||||
|
suburb: "N/A",
|
||||||
|
crossStreet: "N/A",
|
||||||
|
reference: "N/A",
|
||||||
|
daneCode: this.options_.originDaneCode,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
company: "N/A",
|
||||||
|
firstName: address?.first_name || "Cliente",
|
||||||
|
lastName: address?.last_name || "ARI",
|
||||||
|
email: address?.email || order?.email || "[email protected]",
|
||||||
|
phone: address?.phone || "0000000000",
|
||||||
|
address: address?.address_1 || "Dirección de entrega",
|
||||||
|
suburb: "N/A",
|
||||||
|
crossStreet: "N/A",
|
||||||
|
reference: "N/A",
|
||||||
|
daneCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
idOrder: guide.idOrder,
|
||||||
|
tracker: guide.tracker,
|
||||||
|
labelUrl: guide.url,
|
||||||
|
},
|
||||||
|
labels: [
|
||||||
|
{
|
||||||
|
tracking_number: guide.tracker,
|
||||||
|
tracking_url: guide.url,
|
||||||
|
label_url: guide.url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelFulfillment(data: Record<string, unknown>): Promise<any> {
|
||||||
|
const idOrder = data.idOrder;
|
||||||
|
if (typeof idOrder !== "number") {
|
||||||
|
this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick");
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFulfillmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
|
||||||
|
// Devoluciones no estan en alcance de esta primera version.
|
||||||
|
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface EnvioClickOptions {
|
||||||
|
apiKey: string;
|
||||||
|
// El unico endpoint con modo de pruebas real es la generacion de guia
|
||||||
|
// (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas
|
||||||
|
// reales, no hay nada que alternar ahi.
|
||||||
|
sandbox: boolean;
|
||||||
|
// Datos de la bodega de origen -- la stock location real todavia no
|
||||||
|
// tiene direccion fisica cargada ("Sin dirección física registrada"),
|
||||||
|
// asi que estos vienen de config hasta que se cargue una real.
|
||||||
|
originDaneCode: string;
|
||||||
|
originAddress: string;
|
||||||
|
originCompany: string;
|
||||||
|
originFirstName: string;
|
||||||
|
originLastName: string;
|
||||||
|
originEmail: string;
|
||||||
|
originPhone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones
|
||||||
|
// reales del pedido (hoy ningun producto los tiene cargados) -- mismo
|
||||||
|
// valor que ya usaba la shipping option fija de ADR 0009, para no
|
||||||
|
// empeorar la experiencia mientras no haya datos reales.
|
||||||
|
export const FALLBACK_PRICE_COP = 15000;
|
||||||
|
|
||||||
|
// Paquete por defecto cuando algun item del carrito no tiene
|
||||||
|
// peso/dimensiones reales -- conservador, no preciso. Ver nota en el
|
||||||
|
// plan de implementacion: esto es una tarea de datos, no de codigo.
|
||||||
|
export const DEFAULT_PACKAGE = {
|
||||||
|
weight: 1,
|
||||||
|
length: 20,
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
|
||||||
|
import EpaycoPseProviderService from "./service";
|
||||||
|
|
||||||
|
export default ModuleProvider(Modules.PAYMENT, {
|
||||||
|
services: [EpaycoPseProviderService],
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
|
||||||
|
import type {
|
||||||
|
AuthorizePaymentInput,
|
||||||
|
AuthorizePaymentOutput,
|
||||||
|
CancelPaymentInput,
|
||||||
|
CancelPaymentOutput,
|
||||||
|
CapturePaymentInput,
|
||||||
|
CapturePaymentOutput,
|
||||||
|
DeletePaymentInput,
|
||||||
|
DeletePaymentOutput,
|
||||||
|
GetPaymentStatusInput,
|
||||||
|
GetPaymentStatusOutput,
|
||||||
|
InitiatePaymentInput,
|
||||||
|
InitiatePaymentOutput,
|
||||||
|
Logger,
|
||||||
|
ProviderWebhookPayload,
|
||||||
|
RefundPaymentInput,
|
||||||
|
RefundPaymentOutput,
|
||||||
|
RetrievePaymentInput,
|
||||||
|
RetrievePaymentOutput,
|
||||||
|
UpdatePaymentInput,
|
||||||
|
UpdatePaymentOutput,
|
||||||
|
WebhookActionResult,
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
import { createEpaycoClient, verifyWebhookSignature } from "../../lib/epayco";
|
||||||
|
import type { EpaycoPseInputData, EpaycoPseOptions, EpaycoPseSessionData } from "./types";
|
||||||
|
|
||||||
|
type InjectedDependencies = {
|
||||||
|
logger: Logger;
|
||||||
|
};
|
||||||
|
|
||||||
|
// PSE via ePayco es asincrono igual que PayU/Rapyd: no hay confirmacion
|
||||||
|
// real hasta que llega el webhook (url_confirmation), nunca en la
|
||||||
|
// respuesta sincrona de bank.create(). authorizePayment/capturePayment
|
||||||
|
// por eso son casi no-ops -- el estado real lo trae siempre el webhook.
|
||||||
|
export default class EpaycoPseProviderService extends AbstractPaymentProvider<EpaycoPseOptions> {
|
||||||
|
static identifier = "epayco";
|
||||||
|
|
||||||
|
protected logger_: Logger;
|
||||||
|
protected options_: EpaycoPseOptions;
|
||||||
|
|
||||||
|
constructor(container: InjectedDependencies, options: EpaycoPseOptions) {
|
||||||
|
super(container, options);
|
||||||
|
this.logger_ = container.logger;
|
||||||
|
this.options_ = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
static validateOptions(options: Record<string, unknown>): void {
|
||||||
|
for (const key of ["publicKey", "privateKey", "pCustIdCliente", "pKey", "env", "responseUrl", "notifyUrl"]) {
|
||||||
|
if (!options[key]) {
|
||||||
|
throw new Error(`epayco-pse: falta la opción "${key}" en medusa-config.ts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
|
||||||
|
const data = input.data as unknown as EpaycoPseInputData & { session_id: string };
|
||||||
|
const sessionId = data.session_id;
|
||||||
|
const amount = new BigNumber(input.amount).numeric;
|
||||||
|
const currency = input.currency_code.toUpperCase();
|
||||||
|
|
||||||
|
const client = createEpaycoClient(this.options_);
|
||||||
|
|
||||||
|
// Campos confirmados leyendo node_modules/epayco-sdk-node/lib/keylang.json
|
||||||
|
// y tests/bank.js directamente (no la documentacion publica -- esa
|
||||||
|
// mostraba "metodoconfirmacion", el codigo real que de verdad manda
|
||||||
|
// el SDK usa "method_confirmation"). El SDK cifra estos valores y
|
||||||
|
// agrega ip/test/auth por su cuenta, no hace falta hacerlo nosotros.
|
||||||
|
const response = await client.bank.create({
|
||||||
|
bank: data.bank_code,
|
||||||
|
invoice: sessionId,
|
||||||
|
description: `Orden ARI Shopping ${sessionId}`,
|
||||||
|
value: String(amount),
|
||||||
|
tax: "0",
|
||||||
|
tax_base: "0",
|
||||||
|
currency,
|
||||||
|
type_person: data.user_type,
|
||||||
|
doc_type: data.doc_type,
|
||||||
|
doc_number: data.doc_number,
|
||||||
|
name: data.first_name,
|
||||||
|
last_name: data.last_name,
|
||||||
|
email: data.email,
|
||||||
|
country: "CO",
|
||||||
|
city: data.city,
|
||||||
|
cell_phone: data.phone,
|
||||||
|
url_response: this.options_.responseUrl,
|
||||||
|
url_confirmation: this.options_.notifyUrl,
|
||||||
|
method_confirmation: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(`ePayco rechazó la transacción para la sesión ${sessionId}: ${JSON.stringify(response).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El nombre real del campo con la URL de redirección al banco NO
|
||||||
|
// quedó verificado contra una respuesta real de sandbox en esta
|
||||||
|
// sesión (ni la doc ni los tests del SDK lo muestran) -- se prueban
|
||||||
|
// los candidatos más probables y, si ninguno aparece, se tira un
|
||||||
|
// error con el payload completo para poder leerlo en los logs y
|
||||||
|
// corregir esto en el primer intento real contra el sandbox.
|
||||||
|
const flat = (response.data ?? response) as Record<string, unknown>;
|
||||||
|
const bankUrlCandidates = ["urlbanco", "url_banco", "urlBanco", "url", "redirect_url"];
|
||||||
|
const bankUrl = bankUrlCandidates.map((key) => flat[key]).find((value) => typeof value === "string" && value);
|
||||||
|
|
||||||
|
if (typeof bankUrl !== "string" || !bankUrl) {
|
||||||
|
throw new Error(
|
||||||
|
`ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData: EpaycoPseSessionData = {
|
||||||
|
...data,
|
||||||
|
session_id: sessionId,
|
||||||
|
ref_payco: String(flat.ref_payco ?? flat.refPayco ?? ""),
|
||||||
|
transaction_id: String(flat.transactionID ?? flat.transaction_id ?? ""),
|
||||||
|
bank_url: bankUrl,
|
||||||
|
state: "PENDING",
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSE es diferido: en el momento de completar el carrito, ePayco
|
||||||
|
// todavia no confirmo nada -- "pending_authorization" es el mecanismo
|
||||||
|
// documentado de Medusa para esto (la orden queda "awaiting" y el
|
||||||
|
// navegador ya puede redirigirse al banco).
|
||||||
|
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
|
||||||
|
return { data: input.data, status: "pending_authorization" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Casi no-op: cuando el webhook reporta "captured", ePayco ya liquido
|
||||||
|
// el dinero de su lado -- solo persiste el data actualizado.
|
||||||
|
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
|
||||||
|
return { data: input.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
|
||||||
|
this.logger_.warn("epayco-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
|
||||||
|
return { data: input.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
|
||||||
|
return { data: input.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint de reembolso de ePayco no quedó verificado en vivo en esta
|
||||||
|
// sesión -- con dinero real de por medio, mejor tirar un error
|
||||||
|
// explícito que fingir un reembolso que nunca se procesó.
|
||||||
|
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
|
||||||
|
throw new Error(
|
||||||
|
"epayco-pse: refundPayment no está implementado todavía -- procesar el reembolso manualmente desde el panel de ePayco por ahora.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /validation/v1/reference/{ref_payco} -- endpoint de verificación
|
||||||
|
// server-to-server documentado por ePayco para consultar el estado
|
||||||
|
// real de una transacción.
|
||||||
|
async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
|
||||||
|
const data = input.data as unknown as EpaycoPseSessionData | undefined;
|
||||||
|
const refPayco = data?.ref_payco;
|
||||||
|
if (!refPayco) {
|
||||||
|
throw new Error("epayco-pse: retrievePayment necesita ref_payco, no está guardado en esta sesión.");
|
||||||
|
}
|
||||||
|
const response = await fetch(`https://secure.epayco.co/validation/v1/reference/${refPayco}`);
|
||||||
|
const json = await response.json();
|
||||||
|
return { data: json as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
|
||||||
|
// ePayco no tiene concepto de "modificar" una transaccion ya
|
||||||
|
// enviada. El checkout solo llega al step de pago despues de fijar
|
||||||
|
// direccion y envio, asi que el total no deberia cambiar una vez que
|
||||||
|
// existe una sesion PSE.
|
||||||
|
return { data: input.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
|
||||||
|
const data = input.data as unknown as EpaycoPseSessionData | undefined;
|
||||||
|
const state = data?.state;
|
||||||
|
if (state === "1") return { status: "captured" };
|
||||||
|
if (state === "3") return { status: "pending" };
|
||||||
|
if (state === "PENDING") return { status: "pending" };
|
||||||
|
return { status: "canceled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
|
||||||
|
// firma no matchea, no se hace nada -- nunca se confia en un webhook
|
||||||
|
// sin firma valida.
|
||||||
|
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
|
||||||
|
const body = payload.data as Record<string, unknown>;
|
||||||
|
const refPayco = String(body.x_ref_payco ?? "");
|
||||||
|
const transactionId = String(body.x_transaction_id ?? "");
|
||||||
|
const amount = String(body.x_amount ?? "");
|
||||||
|
const currencyCode = String(body.x_currency_code ?? "");
|
||||||
|
const codState = String(body.x_cod_transaction_state ?? "");
|
||||||
|
const signature = String(body.x_signature ?? "");
|
||||||
|
// session_id sale del invoice que nosotros mandamos -- ePayco lo
|
||||||
|
// devuelve como x_id_invoice (o x_extra1 si no viniera) segun el
|
||||||
|
// webhook real; se intentan ambos.
|
||||||
|
const sessionId = String(body.x_id_invoice ?? body.x_extra1 ?? refPayco);
|
||||||
|
|
||||||
|
const validSignature = verifyWebhookSignature({
|
||||||
|
pCustIdCliente: this.options_.pCustIdCliente,
|
||||||
|
pKey: this.options_.pKey,
|
||||||
|
refPayco,
|
||||||
|
transactionId,
|
||||||
|
amount,
|
||||||
|
currencyCode,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validSignature) {
|
||||||
|
this.logger_.warn(`epayco-pse: webhook con firma inválida para x_ref_payco=${refPayco}, ignorado`);
|
||||||
|
return { action: "not_supported" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo "1" (Aceptada) es exito -- cualquier otro codigo (2 Rechazada,
|
||||||
|
// 4 Fallida, 6 Reversada, 9 caducada, 10 abandonada, 11 Cancelada,
|
||||||
|
// 3 Pendiente) se trata como fallido en vez de asumir un exito
|
||||||
|
// distinto. Un "failed" de mas es reintentable; un "captured" de mas
|
||||||
|
// (dinero que nunca se aprobo) no lo es.
|
||||||
|
const action = codState === "1" ? "captured" : "failed";
|
||||||
|
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
data: {
|
||||||
|
session_id: sessionId,
|
||||||
|
amount: new BigNumber(amount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { EpaycoEnv } from "../../lib/epayco";
|
||||||
|
|
||||||
|
export interface EpaycoPseOptions {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
// p_cust_id_cliente / p_key -- valores propios del dashboard de ePayco
|
||||||
|
// para verificar la firma del webhook, distintos de publicKey/privateKey.
|
||||||
|
pCustIdCliente: string;
|
||||||
|
pKey: string;
|
||||||
|
env: EpaycoEnv;
|
||||||
|
// A donde ePayco redirige al comprador de vuelta tras pagar en el
|
||||||
|
// banco (pagina propia del storefront, no confirma nada por si sola).
|
||||||
|
responseUrl: string;
|
||||||
|
// URL publica de confirmacion -- por-transaccion (a diferencia de
|
||||||
|
// Rapyd, que solo permite configurarlo una vez a nivel de cuenta),
|
||||||
|
// apunta a la ruta nativa de Medusa /hooks/payment/:provider.
|
||||||
|
notifyUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EpaycoDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
|
||||||
|
// "0" persona natural, "1" persona juridica -- valores reales que espera
|
||||||
|
// el campo type_person de ePayco.
|
||||||
|
export type EpaycoUserType = "0" | "1";
|
||||||
|
|
||||||
|
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
|
||||||
|
// (recolectado en el step "address" del checkout, mas el banco/tipo de
|
||||||
|
// persona/documento del nuevo step "payment"). No viene de
|
||||||
|
// `context.customer` porque el checkout es de invitado -- ver ADR 0009.
|
||||||
|
export interface EpaycoPseInputData {
|
||||||
|
bank_code: string;
|
||||||
|
user_type: EpaycoUserType;
|
||||||
|
doc_type: EpaycoDocType;
|
||||||
|
doc_number: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lo que initiatePayment devuelve y queda guardado en `data` de la
|
||||||
|
// PaymentSession -- publico para el storefront, nunca poner credenciales
|
||||||
|
// acá. `bank_url`: nombre de campo AUN NO VERIFICADO contra una
|
||||||
|
// respuesta real de sandbox (ver comentario en service.ts) -- primer
|
||||||
|
// punto a confirmar en la primera prueba end-to-end.
|
||||||
|
export interface EpaycoPseSessionData extends EpaycoPseInputData {
|
||||||
|
session_id: string;
|
||||||
|
ref_payco: string;
|
||||||
|
transaction_id: string;
|
||||||
|
bank_url: string;
|
||||||
|
state: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@medusajs/framework/utils"
|
||||||
|
import ProductReviewModuleService from "./service"
|
||||||
|
|
||||||
|
export const PRODUCT_REVIEW_MODULE = "productReview"
|
||||||
|
|
||||||
|
export default Module(PRODUCT_REVIEW_MODULE, {
|
||||||
|
service: ProductReviewModuleService,
|
||||||
|
})
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
{
|
||||||
|
"namespaces": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"name": "public",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": true,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"product_id": {
|
||||||
|
"name": "product_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"order_id": {
|
||||||
|
"name": "order_id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"name": "rating",
|
||||||
|
"type": "integer",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "integer"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": null,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "'pending'",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [
|
||||||
|
"pending",
|
||||||
|
"approved",
|
||||||
|
"rejected"
|
||||||
|
],
|
||||||
|
"mappedType": "enum"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "now()",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": "now()",
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"unique": false,
|
||||||
|
"length": 6,
|
||||||
|
"precision": null,
|
||||||
|
"scale": null,
|
||||||
|
"default": null,
|
||||||
|
"comment": null,
|
||||||
|
"enumItems": [],
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "product_review",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_deleted_at",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_product_id_status",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_customer_id",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "IDX_product_review_product_id_customer_id_unique",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "product_review_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": true,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {},
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||||
|
|
||||||
|
export class Migration20260927062845 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
|
||||||
|
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
|
||||||
|
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop table if exists "product_review" cascade;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
|
||||||
|
// solo compradores verificados -- customer_id + order_id se validan en
|
||||||
|
// POST /store/reviews contra un pedido real del cliente que contenga el
|
||||||
|
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
|
||||||
|
// dueño las aprueba desde el admin (pagina "Reseñas").
|
||||||
|
export const ProductReview = model
|
||||||
|
.define("product_review", {
|
||||||
|
id: model.id({ prefix: "prev" }).primaryKey(),
|
||||||
|
product_id: model.text(),
|
||||||
|
customer_id: model.text(),
|
||||||
|
order_id: model.text(),
|
||||||
|
author_name: model.text(),
|
||||||
|
rating: model.number(),
|
||||||
|
title: model.text().nullable(),
|
||||||
|
content: model.text(),
|
||||||
|
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
|
||||||
|
})
|
||||||
|
.indexes([
|
||||||
|
{ on: ["product_id", "status"] },
|
||||||
|
{ on: ["customer_id"] },
|
||||||
|
// Una reseña por cliente por producto.
|
||||||
|
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
|
||||||
|
])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MedusaService } from "@medusajs/framework/utils"
|
||||||
|
import { ProductReview } from "./models/product-review"
|
||||||
|
|
||||||
|
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
|
||||||
|
|
||||||
|
export default ProductReviewModuleService
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk";
|
||||||
|
import { attachInventoryItemToVariants } from "@medusajs/medusa/core-flows";
|
||||||
|
|
||||||
|
// attachInventoryItemToVariants es un step suelto (no un workflow
|
||||||
|
// exportado) -- confirmado leyendo el .js real instalado
|
||||||
|
// (@medusajs/core-flows/dist/inventory/steps/attach-inventory-items.js):
|
||||||
|
// internamente resuelve ContainerRegistrationKeys.LINK y crea el link
|
||||||
|
// Product<->Inventory. El campo "tag" del input es el variant_id (nombre
|
||||||
|
// generico del step, no documentado con ese detalle).
|
||||||
|
type Input = { inventoryItemId: string; variantId: string };
|
||||||
|
|
||||||
|
export const attachInventoryItemToVariantWorkflow = createWorkflow(
|
||||||
|
"attach-inventory-item-to-variant",
|
||||||
|
(input: Input) => {
|
||||||
|
return new WorkflowResponse(
|
||||||
|
attachInventoryItemToVariants([
|
||||||
|
{ inventoryItemId: input.inventoryItemId, tag: input.variantId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -82,3 +82,69 @@
|
|||||||
uno de "agregar una clave a un secret ya vivo" deberían haber sido
|
uno de "agregar una clave a un secret ya vivo" deberían haber sido
|
||||||
dos herramientas distintas desde el principio, no una sola que hay
|
dos herramientas distintas desde el principio, no una sola que hay
|
||||||
que saber cuándo NO correr completa.
|
que saber cuándo NO correr completa.
|
||||||
|
|
||||||
|
## CSS Grid: un scroller horizontal sin `min-w-0` puede desbordar todo el grid
|
||||||
|
|
||||||
|
Encontrado en producción (2026-09-13/14) en la página de producto del
|
||||||
|
storefront: un síntoma que parecía un bug de imágenes ("se ve como si
|
||||||
|
abriera en tamaño real" en Chrome/Edge Android) resultó ser, en
|
||||||
|
realidad, un desborde de layout. El carrusel horizontal de "También te
|
||||||
|
puede gustar" (`overflow-x-auto`, sin `flex-wrap`) vivía como hijo
|
||||||
|
directo de un CSS Grid. Por spec, el tamaño mínimo automático de un
|
||||||
|
item de grid es el tamaño mínimo de su contenido **a menos que el
|
||||||
|
propio item** tenga `overflow` distinto de `visible` — y como el `div`
|
||||||
|
que envolvía al carrusel no lo tenía, el ancho mínimo intrínseco de
|
||||||
|
*todas* las tarjetas del carrusel sin wrappear se contaba como el
|
||||||
|
mínimo de esa columna, forzando **todo el grid compartido** (imagen,
|
||||||
|
título, specs) a ~800px de ancho en un viewport real de ~380px.
|
||||||
|
|
||||||
|
**Lección reusable:** cualquier fila con scroll horizontal (carrusel,
|
||||||
|
tira de miniaturas, chips de filtro) que sea hijo directo de un CSS
|
||||||
|
Grid necesita `min-w-0` en su wrapper (o `overflow` seteado
|
||||||
|
directamente en el item de grid) — si no, su contenido sin wrappear
|
||||||
|
puede reventar el ancho de todo el grid en silencio, y el síntoma
|
||||||
|
visible no tiene nada que ver con imágenes ni con DPR. Diagnosticado
|
||||||
|
en vivo comparando `window.innerWidth` (reportaba el ancho
|
||||||
|
desbordado) contra el viewport real del dispositivo.
|
||||||
|
|
||||||
|
## Rediseño de página de producto y correcciones de datos reales (2026-09-13/14)
|
||||||
|
|
||||||
|
Sesión de correcciones guiadas por capturas reales del usuario en
|
||||||
|
navegadores reales (no reportes abstractos):
|
||||||
|
|
||||||
|
- **Bodega mal configurada:** `ENVIOCLICK_ORIGIN_DANE_CODE` apuntaba a
|
||||||
|
Bogotá (`11001000`) cuando la bodega real está en Santiago de Cali,
|
||||||
|
Valle del Cauca (`76001000`) — afectaba el cálculo real de envío
|
||||||
|
(EnvíoClick) en cada checkout. Corregido en el ConfigMap del
|
||||||
|
frontend, en `commerce-backend/medusa-config.ts` (incluyendo un
|
||||||
|
fallback obsoleto que también apuntaba a Bogotá) y en el registro
|
||||||
|
real de la bodega en Medusa (vía admin temporal, mismo patrón que el
|
||||||
|
resto del lab para ediciones puntuales en vivo).
|
||||||
|
- **Redes sociales y páginas de ayuda reales:** el footer enlazaba
|
||||||
|
cuentas placeholder; se verificó en vivo la cuenta real de Instagram
|
||||||
|
antes de linkearla (la primera sugerida no era la correcta) y se
|
||||||
|
activaron los enlaces reales de Instagram/Facebook/WhatsApp, además
|
||||||
|
de las páginas `/cambios` y `/preguntas-frecuentes` con contenido
|
||||||
|
real (ya existían en código, no estaban enlazadas).
|
||||||
|
- **Zoom estilo Amazon en la galería de producto:** iteración en tres
|
||||||
|
pasos hasta llegar a lo pedido — `transform: scale()` sobre una
|
||||||
|
imagen ya recortada por `object-cover` (no funcionaba: lo recortado
|
||||||
|
nunca estaba en el DOM), superposición in-place (técnicamente
|
||||||
|
correcta pero no la UX pedida), y finalmente una lupa que sigue el
|
||||||
|
cursor más un panel lateral que magnifica esa región — el patrón
|
||||||
|
real de Amazon. El tamaño/posición del panel se calcula en JS contra
|
||||||
|
`window.innerWidth/innerHeight` en cada `mousemove` (no un
|
||||||
|
breakpoint CSS fijo), con fallback a la superposición in-place si no
|
||||||
|
hay espacio.
|
||||||
|
- **Barra flotante de "Añadir al carrito" en mobile: removida, no
|
||||||
|
reajustada.** Pasó por varias iteraciones de lógica de
|
||||||
|
mostrar/ocultar (con `IntersectionObserver`) que seguían
|
||||||
|
superponiéndose a otro contenido en capturas reales del usuario. En
|
||||||
|
vez de seguir ajustando el umbral, se eliminó el componente por
|
||||||
|
completo y se dejó el botón inline normal, visible también en
|
||||||
|
mobile — el mismo patrón que usa la mayoría de tiendas. Verificado
|
||||||
|
con Playwright: cero elementos `position: fixed` en la página de
|
||||||
|
producto.
|
||||||
|
- **Ambiente de Staging + versión real en el footer:** ver
|
||||||
|
[ADR 0011](../decisiones/0011-ambiente-staging.md) para el detalle
|
||||||
|
completo.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Plugins y subagentes de Claude Code (sesión "lab")
|
||||||
|
|
||||||
|
Inventario de qué plugins/skills se instalaron en la sesión principal
|
||||||
|
`lab` (no `cloud-practice`, que sigue su propia línea de práctica
|
||||||
|
multi-cloud — ver [ADR 0012](../decisiones/0012-practica-multicloud-gcp-azure.md))
|
||||||
|
y qué subagentes propios se configuraron, con la fuente real de cada
|
||||||
|
uno y por qué se aceptó o se descartó.
|
||||||
|
|
||||||
|
## Por qué esto queda documentado como el resto del lab
|
||||||
|
|
||||||
|
Dos frentes reales motivaron esto: (a) trabajo visual del storefront
|
||||||
|
ARI Shopping — ADR 0008 ya costó tres ciclos completos de
|
||||||
|
implementar-y-descartar una identidad en producción por no verla
|
||||||
|
renderizada antes de aprobarla; (b) crecimiento de habilidades de
|
||||||
|
Platform Engineering/DevOps. Un plugin instala instrucciones que
|
||||||
|
Claude sigue automáticamente, algunos con hooks que se ejecutan sin
|
||||||
|
pedir confirmación en cada uso — se aplicó la misma disciplina de
|
||||||
|
verificación que el resto del lab: quién publica (Anthropic vs
|
||||||
|
comunitario), qué permisos/hooks declara, y popularidad como señal de
|
||||||
|
confianza, antes de instalar cualquiera que no fuera 100% de Anthropic.
|
||||||
|
|
||||||
|
## Plugins instalados
|
||||||
|
|
||||||
|
| Plugin | Marketplace | Publica de verdad | Hooks | Notas |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `frontend-design` | `claude-plugins-official` | **Anthropic** (verificado en `plugin.json`) | Ninguno | Solo skills, sin red externa |
|
||||||
|
| `context7` | `claude-plugins-official` | **Upstash** (tag `community-managed` — está *hospedado* en el catálogo oficial, no *publicado* por Anthropic) | Sin hooks, pero es un MCP que manda cada consulta al servidor remoto de Upstash (`mcp.context7.com`) | Aceptado con esa salvedad explícita de datos saliendo a un tercero |
|
||||||
|
| `superpowers` | `claude-plugins-official` | **Jesse Vincent** (`obra/superpowers`, MIT, 288k+ ⭐ en GitHub) — **NO es de Anthropic** pese a estar listado en el catálogo oficial | Sí: `SessionStart` corre un script que inyecta contexto en cada sesión nueva (revisado: solo lee su propio skill local, sin red ni nada destructivo) | Aceptado con esa salvedad — cambia comportamiento de arranque sin pedir confirmación cada vez |
|
||||||
|
| `gitops-workflows` | `devops-skills` (`ahmedasmar/devops-claude-skills`) | Ahmad Asmar (comunitario) | Ninguno (skill pura, sin ejecución automática) | |
|
||||||
|
| `k8s-troubleshooter` | `devops-skills` | Ahmad Asmar | Ninguno | |
|
||||||
|
| `ci-cd` | `devops-skills` | Ahmad Asmar | Ninguno | |
|
||||||
|
| `monitoring-observability` | `devops-skills` | Ahmad Asmar | Ninguno | |
|
||||||
|
|
||||||
|
**Descartados, no instalados:**
|
||||||
|
|
||||||
|
- **`iac-terraform`** (del mismo marketplace `devops-skills`) — descartado a propósito, no por falta de calidad. Terraform ya se maneja aparte vía `hashicorp/agent-skills` en la sesión `cloud-practice` (ver ADR 0012 Fase 2); instalarlo acá también duplicaría/confundiría la fuente de verdad.
|
||||||
|
- **`aws-cost-optimization`** (mismo marketplace) — no se pidió, se dejó afuera para no sumar alcance no solicitado.
|
||||||
|
- **`theme-factory`** — investigado y descartado: el mismo nombre aparece en al menos 4 fuentes comunitarias distintas y no relacionadas entre sí (`kivilaid`, `shipshitdev`, `composiohq`, etc.), ninguna en el catálogo oficial de Anthropic. Sin una fuente única verificable, no se instaló nada con ese nombre.
|
||||||
|
- **`ui-theme-designer`** (alternativa considerada del catálogo oficial) — es de **SAP SE**, específico para el SAP Fiori Design System / UI5 Web Components. No tiene nada que ver con el stack real de ARI Shopping (Next.js + Tailwind) — se descartó por no aplicar, no por falta de confianza.
|
||||||
|
|
||||||
|
## Subagentes propios (`~/.claude/agents/`)
|
||||||
|
|
||||||
|
Tres subagentes con propósito acotado y tools restringidas, definidos
|
||||||
|
como archivos markdown con frontmatter YAML (formato estándar de
|
||||||
|
Claude Code — ver [documentación oficial](https://code.claude.com/docs/en/sub-agents)).
|
||||||
|
|
||||||
|
### `devsecops-auditor`
|
||||||
|
|
||||||
|
Solo lectura y explicación del pipeline DevSecOps real (Gitleaks,
|
||||||
|
Trivy, Semgrep, Syft, Cosign) en `apps-registry`/`platform-infra`/
|
||||||
|
`scripts`. `tools: Read, Grep, Glob, Bash` + `disallowedTools: Write,
|
||||||
|
Edit` — nunca modifica código de producción, su único output es
|
||||||
|
diagnóstico contra el YAML/config real, citando archivo:línea, nunca
|
||||||
|
teoría genérica de CI/CD.
|
||||||
|
|
||||||
|
### `frontend-critic`
|
||||||
|
|
||||||
|
Revisa visualmente un cambio del storefront **antes** de aprobarlo —
|
||||||
|
formaliza la disciplina "simular antes de implementar" que costó tres
|
||||||
|
ciclos de descarte en ADR 0008. Regla dura: si no puede ver el
|
||||||
|
resultado renderizado en esa corrida puntual, el veredicto es "no
|
||||||
|
puedo aprobar esto sin verlo", nunca un aprobado condicional.
|
||||||
|
|
||||||
|
Vía de verificación, en orden de preferencia:
|
||||||
|
1. Chrome DevTools / Claude in Chrome — **hoy no disponible en esta
|
||||||
|
sesión** (corre headless dentro de `tmux-host`, sin navegador real
|
||||||
|
conectado — mismo gotcha ya documentado para Claude in Chrome en
|
||||||
|
general).
|
||||||
|
2. Playwright vía Docker en la NAS (el workaround ya validado este
|
||||||
|
mismo lab, ver `.browser-scratch/README.md`) contra
|
||||||
|
`shop.cruzcloud.net`/`staging.cruzcloud.net` reales.
|
||||||
|
|
||||||
|
Usa la skill `frontend-design` (oficial, ver tabla de arriba) como
|
||||||
|
marco de criterio de calidad visual.
|
||||||
|
|
||||||
|
### `cloud-cost-auditor`
|
||||||
|
|
||||||
|
Definido de antemano, a propósito, **sin nada que auditar todavía**.
|
||||||
|
Necesita los MCP de GCP/Azure que se configuran en la sesión
|
||||||
|
`cloud-practice` (ADR 0012) para funcionar — no va a tener recursos
|
||||||
|
reales que revisar hasta que esa línea de trabajo pase de la Fase 1.
|
||||||
|
Su propio system prompt tiene la regla dura de decir explícitamente
|
||||||
|
"no tengo nada que auditar todavía" en vez de inventar un análisis si
|
||||||
|
lo invocan antes de tiempo. No es un bloqueante para nada de lo demás,
|
||||||
|
solo queda listo para cuando aplique.
|
||||||
|
|
||||||
|
## Alcance y persistencia de la instalación
|
||||||
|
|
||||||
|
Los 7 plugins quedaron en **`scope: user`** (confirmado con
|
||||||
|
`claude plugin list`), no a nivel de proyecto de un repo específico.
|
||||||
|
Los 3 subagentes viven en `~/.claude/agents/` (también scope de
|
||||||
|
usuario, ubicación estándar según la doc oficial).
|
||||||
|
|
||||||
|
**Verificado en vivo que sobrevive a un reinicio del contenedor**,
|
||||||
|
mismo criterio que el resto de la configuración persistente de
|
||||||
|
`tmux-host` (ver [Sesión tmux persistente](sesion-tmux-persistente.md)):
|
||||||
|
`stat -c '%d' /root/.claude` y `stat -c '%d'
|
||||||
|
/DATA/AppData/k3d-lab-v3/claude-code-home` devuelven el mismo device
|
||||||
|
ID — es el mismo bind mount ya validado para login/confianza de
|
||||||
|
carpeta, no un directorio efímero del contenedor.
|
||||||
|
|
||||||
|
**Gotcha encontrado en vivo:** tanto los plugins como los subagentes
|
||||||
|
nuevos **no quedaron activos en la misma sesión donde se instalaron/
|
||||||
|
crearon** — la doc oficial de subagentes dice que se cargan sin
|
||||||
|
reinicio, *excepto* la primera vez que se crea el directorio
|
||||||
|
`~/.claude/agents/` (que no existía antes en este lab). Confirmado en
|
||||||
|
vivo: invocar `devsecops-auditor` en la misma sesión dio
|
||||||
|
`Agent type 'devsecops-auditor' not found`. Ambos (plugins y
|
||||||
|
subagentes) quedan pendientes de confirmar activos recién en el
|
||||||
|
próximo arranque real de la sesión — no es una falla de instalación,
|
||||||
|
es el comportamiento documentado.
|
||||||
@@ -37,6 +37,9 @@ flowchart LR
|
|||||||
SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
|
SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
|
||||||
C --> T["tmux server<br/>sesión 'lab'"]
|
C --> T["tmux server<br/>sesión 'lab'"]
|
||||||
T --> CC["proceso claude<br/>(Claude Code)"]
|
T --> CC["proceso claude<br/>(Claude Code)"]
|
||||||
|
C --> HC["healthcheck.sh (background)<br/>cada 30 min"]
|
||||||
|
HC -.->|curl| NET["gitea.cruzcloud.net"]
|
||||||
|
HC -.->|recrea si falta| T
|
||||||
C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
|
C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
|
||||||
C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
|
C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
|
||||||
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]
|
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]
|
||||||
@@ -49,7 +52,17 @@ reinicia.
|
|||||||
|
|
||||||
## Contenedor: `tmux-host`
|
## Contenedor: `tmux-host`
|
||||||
|
|
||||||
|
La imagen se construye desde `scripts/tmux-host/Dockerfile` (antes era
|
||||||
|
un `docker run ... alpine sh -c "apk add ..."` inline — mismo contenido,
|
||||||
|
pero ahora como imagen versionada en vez de reinstalar todo en cada
|
||||||
|
arranque). El Dockerfile fija `WORKDIR /DATA/AppData/k3d-lab-v3/lab-cruzcloud`
|
||||||
|
(la raíz del lab, con `CLAUDE.md`, `apps-registry/`, `platform-infra/` y
|
||||||
|
`scripts/`) y agrega `healthcheck.sh` (ver más abajo) como proceso en
|
||||||
|
background del contenedor.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker build -t tmux-host:local scripts/tmux-host
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name tmux-host \
|
--name tmux-host \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
@@ -59,9 +72,15 @@ docker run -d \
|
|||||||
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
|
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
|
||||||
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
|
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
|
||||||
--network host \
|
--network host \
|
||||||
alpine sh -c "apk add --no-cache tmux docker-cli bash curl git kubectl nodejs npm && npm install -g @anthropic-ai/claude-code && git config --global --add safe.directory '*' && tail -f /dev/null"
|
tmux-host:local
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`WORKDIR` hace que `docker exec` (y por lo tanto `lab-session.sh`)
|
||||||
|
arranquen ya posicionados en la raíz del lab sin tener que navegar a
|
||||||
|
mano — `lab-session.sh` además pasa la ruta explícita vía `tmux new -c`
|
||||||
|
por las dudas, para no depender de que `docker exec` herede el `WORKDIR`
|
||||||
|
de la imagen.
|
||||||
|
|
||||||
| Volumen | Destino | Por qué |
|
| Volumen | Destino | Por qué |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
|
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
|
||||||
@@ -195,13 +214,208 @@ Si la sesión `lab` existe, se adjunta a ella (mismo shell, mismo
|
|||||||
ejemplo justo después de que el contenedor se recreó desde cero— la
|
ejemplo justo después de que el contenedor se recreó desde cero— la
|
||||||
crea. No hay que recordar cuál de los dos casos aplica.
|
crea. No hay que recordar cuál de los dos casos aplica.
|
||||||
|
|
||||||
|
## Chequeo de salud automático (cada 30 min)
|
||||||
|
|
||||||
|
`scripts/tmux-host/healthcheck.sh` corre en background dentro del
|
||||||
|
propio contenedor (lanzado por `entrypoint.sh`, el `ENTRYPOINT` de la
|
||||||
|
imagen) y cada `HEALTHCHECK_INTERVAL_SEC` (1800s por defecto) hace dos
|
||||||
|
chequeos independientes, con una respuesta distinta según el tipo de
|
||||||
|
falla:
|
||||||
|
|
||||||
|
- **Conectividad de red real** (`curl` a `https://gitea.cruzcloud.net/`):
|
||||||
|
si falla, **solo se registra** en el log y se notifica por Telegram —
|
||||||
|
el contenedor nunca intenta "arreglar" la red del host. Un problema de
|
||||||
|
red puede ser de hardware/BIOS (deuda técnica ya conocida, ver
|
||||||
|
`incidente-medusa-crashloop-2026-08.md`), fuera del alcance real de un
|
||||||
|
contenedor.
|
||||||
|
- **Existencia de la sesión tmux `lab`** (`tmux has-session`): si no
|
||||||
|
existe, **sí se recrea automáticamente** (`tmux new-session -d -s lab
|
||||||
|
-c <WORKDIR>`, seguido de `tmux send-keys -t lab claude --remote-control
|
||||||
|
--continue Enter` — ver gotcha "sesión recreada pero sin Claude Code
|
||||||
|
adentro" más abajo, y los gotchas de Remote Control y `--continue`
|
||||||
|
agregados el 2026-09-20) — es solo recuperar el propio entorno de
|
||||||
|
trabajo del contenedor, no tocar infraestructura externa, así que es
|
||||||
|
seguro hacerlo sin intervención humana. Si `WORKDIR` todavía no está
|
||||||
|
montado (ver gotcha siguiente),
|
||||||
|
no se crea la sesión esa vuelta — se reintenta en el próximo ciclo en
|
||||||
|
vez de crearla mal posicionada.
|
||||||
|
|
||||||
|
Todo evento (chequeo ok, falla de red, sesión recreada) queda en
|
||||||
|
`/DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log` — persistente en
|
||||||
|
`/DATA`, no solo en el stdout del contenedor (que se pierde con
|
||||||
|
`docker logs` al recrearlo). Las notificaciones de Telegram reusan el
|
||||||
|
mismo bot/`chat_id` ya configurado para `scripts/lab-agent/`
|
||||||
|
(`lab-agent.env`), en vez de duplicar credenciales entre los dos
|
||||||
|
agentes del lab.
|
||||||
|
|
||||||
|
Para probar el intervalo sin esperar 30 minutos, se puede levantar el
|
||||||
|
contenedor con `-e HEALTHCHECK_INTERVAL_SEC=60` (solo para pruebas en un
|
||||||
|
contenedor descartable — nunca cambiar esto en el `tmux-host` real sin
|
||||||
|
volver a 1800 después).
|
||||||
|
|
||||||
|
## Gotcha: sesión recreada, pero sin Claude Code adentro (reinicio completo del NAS, 2026-09-19)
|
||||||
|
|
||||||
|
Distinto al caveat de persistencia de sesión de más abajo (ese cubre "el
|
||||||
|
*proceso* muere pero el contenedor sigue vivo" — cerrar la sesión `lab` a
|
||||||
|
mano, o un crash del propio `claude`). Este es otro escenario: **el HOST
|
||||||
|
completo se apaga y vuelve a arrancar desde cero** (corte de luz, no un
|
||||||
|
`docker restart` ordenado) — reordena cuándo arrancan Docker, la red y los
|
||||||
|
montajes de `/DATA`/`/media/HDD-Storage` entre sí, algo que un
|
||||||
|
`docker restart` normal nunca ejercita porque el host ya estaba arriba.
|
||||||
|
|
||||||
|
Tras un corte de luz real, se encontró que el usuario se reconectó a la
|
||||||
|
sesión `lab` y encontró un shell vacío, no Claude Code corriendo. Dos
|
||||||
|
causas distintas, ambas corregidas:
|
||||||
|
|
||||||
|
1. **La sesión tmux SÍ se recreaba sola** (confirmado en `health.log`: el
|
||||||
|
contenedor arrancó y la sesión `lab` quedó recreada en cuestión de
|
||||||
|
segundos), pero `check_tmux_session()` nunca lanzó `claude` adentro —
|
||||||
|
solo garantizaba que existiera el *shell*, no que el proceso de Claude
|
||||||
|
Code estuviera corriendo. El diagrama de arquitectura de este mismo doc
|
||||||
|
siempre mostró `T --> CC["proceso claude"]` como si fuera automático,
|
||||||
|
pero el código nunca lo implementó — el hueco quedó invisible mientras
|
||||||
|
nadie pasó por un reinicio completo real del NAS (en un `docker
|
||||||
|
restart` normal, el `claude` que ya estaba corriendo dentro de la
|
||||||
|
sesión simplemente sigue vivo, porque ahí el proceso *no* muere).
|
||||||
|
**Fix:** `check_tmux_session()` ahora llama `launch_claude_in_session()`
|
||||||
|
(`tmux send-keys -t lab claude Enter`) justo después de crear la sesión
|
||||||
|
— solo cuando la crea de cero, nunca sobre una sesión que ya existe (no
|
||||||
|
se reinyecta `claude` sobre un pane con trabajo en curso).
|
||||||
|
2. **Riesgo de carrera no ejercitado todavía, pero real:** `WORKDIR`
|
||||||
|
(`/DATA/AppData/k3d-lab-v3/lab-cruzcloud`) llega vía el symlink absoluto
|
||||||
|
`/DATA/AppData -> /media/HDD-Storage/AppData` (ver el gotcha del symlink
|
||||||
|
más arriba). Si el storage externo (`/media/HDD-Storage`) tarda en
|
||||||
|
montarse en el host más de lo que tarda Docker en arrancar este
|
||||||
|
contenedor — plausible después de un corte de luz, no de un simple
|
||||||
|
restart —, `mkdir -p "$LOG_DIR"` fallaba en silencio (`set -u` sin
|
||||||
|
`-e`) y ese arranque quedaba *sin ningún rastro* en el log, y
|
||||||
|
`tmux new-session -c "$WORKDIR"` habría creado la sesión mal
|
||||||
|
posicionada (o fallado) contra un directorio que todavía no existe.
|
||||||
|
**Fix:** `wait_for_workdir()` reintenta con backoff (pasos de 5s, hasta
|
||||||
|
`STARTUP_MOUNT_WAIT_MAX_SEC=120` por defecto) a que `WORKDIR` exista
|
||||||
|
antes de tocar el log o crear la sesión; si sigue sin existir pasado
|
||||||
|
ese máximo, se registra el error explícitamente y se reintenta en el
|
||||||
|
siguiente ciclo (30 min) en vez de fallar una sola vez y quedarse así.
|
||||||
|
|
||||||
|
**Validado en vivo:** imagen reconstruida (`tmux-host:test-fix`) y probada
|
||||||
|
en un contenedor descartable aparte (mismos mounts de `/DATA` y
|
||||||
|
`/media/HDD-Storage`, sin tocar el `tmux-host` real) — `docker stop` +
|
||||||
|
`docker start` (arranque desde cero, no `restart`) recreó la sesión `lab`
|
||||||
|
con `claude` corriendo adentro sin intervención manual, en ~1 segundo.
|
||||||
|
|
||||||
|
## Gotcha: Remote Control nunca se activaba solo (2026-09-20)
|
||||||
|
|
||||||
|
Con la sesión `lab` recreándose sola y `claude` corriendo adentro (gotcha
|
||||||
|
anterior), quedaba un problema distinto y más sutil: la sesión servía
|
||||||
|
perfecto para trabajar vía `docker exec`/`lab-session.sh`, pero nunca
|
||||||
|
aparecía como controlable remotamente en claude.ai — como si Remote
|
||||||
|
Control jamás se hubiera activado.
|
||||||
|
|
||||||
|
**Causa raíz (confirmado en vivo 2026-09-20):** Remote Control es
|
||||||
|
opt-in por lanzamiento, vía el flag `claude --remote-control [nombre]`
|
||||||
|
de la CLI. Nada en `launch_claude_in_session()` lo pasaba nunca, así
|
||||||
|
que `remoteEnabled` en `~/.claude.json` quedaba en `false` para siempre
|
||||||
|
porque nadie lo pedía. No existe un paso separado de confirmación
|
||||||
|
"¿Activar Remote Control? y/n" más allá del diálogo normal de
|
||||||
|
confianza del folder (una hipótesis inicial de que sí existía tal paso,
|
||||||
|
y de que había una clave `remoteControlAtStartup` en `settings.json`,
|
||||||
|
resultó falsa al revisar directamente los archivos de configuración
|
||||||
|
reales).
|
||||||
|
|
||||||
|
**Cómo se detectó:** lanzar `claude --remote-control` a mano (en una
|
||||||
|
ventana de tmux en foreground, no vía un `!` de un solo uso que se
|
||||||
|
mata al terminar) sí registró la sesión y apareció en la lista de
|
||||||
|
sesiones activas de claude.ai como `zimaos-quizzical` — el hostname del
|
||||||
|
contenedor (`ZimaOS`) se usa como prefijo por defecto del nombre de
|
||||||
|
sesión remota.
|
||||||
|
|
||||||
|
**Fix:** cambiar la línea `tmux send-keys` en
|
||||||
|
`launch_claude_in_session()` para lanzar `claude --remote-control` en
|
||||||
|
vez de `claude` a secas.
|
||||||
|
|
||||||
|
**Validado en vivo:** tras reconstruir la imagen y swapear el
|
||||||
|
contenedor real (ver el gotcha del swap más abajo), `health.log`
|
||||||
|
registró `'claude --remote-control' lanzado...` al recrear la sesión, y
|
||||||
|
el pane de tmux mostró `/remote-control is active` con un link de
|
||||||
|
sesión válido — sin que nadie lo activara a mano.
|
||||||
|
|
||||||
|
## Gotcha: cada recreación de la sesión empezaba una conversación en blanco (2026-09-20)
|
||||||
|
|
||||||
|
Con Remote Control ya activándose solo, quedaba una fricción menor pero
|
||||||
|
molesta: cada vez que la sesión `lab` se recreaba (reinicio del NAS,
|
||||||
|
`docker rm` + recrear, o el propio healthcheck reaccionando a un
|
||||||
|
crash), `launch_claude_in_session()` arrancaba una conversación
|
||||||
|
completamente nueva. El contexto de trabajo en curso —no las
|
||||||
|
credenciales ni la confianza del folder, que sí persisten vía volumen—
|
||||||
|
se perdía cada vez, sin relación con los límites de cuota (ver la nota
|
||||||
|
al principio de este doc).
|
||||||
|
|
||||||
|
**Fix:** agregar `-c`/`--continue` (retoma la conversación más reciente
|
||||||
|
en el directorio de trabajo actual) al comando de lanzamiento:
|
||||||
|
`claude --remote-control --continue`. Si no hay ninguna conversación
|
||||||
|
previa en `WORKDIR`, el propio CLI arranca una nueva sin error — no
|
||||||
|
hace falta lógica condicional en el script.
|
||||||
|
|
||||||
|
**Validado en vivo — la prueba más fuerte posible:** el swap que
|
||||||
|
desplegó este mismo cambio (ver gotcha siguiente) mató la sesión de
|
||||||
|
Claude Code que lo estaba ejecutando. Del otro lado, la *misma
|
||||||
|
conversación* siguió sin cortarse, con el mismo ID de sesión, sin
|
||||||
|
ninguna intervención manual — evidencia directa de que `--continue`
|
||||||
|
efectivamente retoma la conversación y no solo que el contenedor volvió
|
||||||
|
a levantar.
|
||||||
|
|
||||||
|
## Gotcha: actualizar la imagen de `tmux-host` en caliente mata al propio proceso que hace el swap (2026-09-20)
|
||||||
|
|
||||||
|
Este contenedor tiene un problema estructural para actualizarse a sí
|
||||||
|
mismo: el proceso de Claude Code que ejecutaría `docker stop tmux-host
|
||||||
|
&& docker rm tmux-host && docker run ... tmux-host:nueva-imagen` corre
|
||||||
|
*dentro* del propio contenedor `tmux-host` — el primer `docker stop`
|
||||||
|
mata su propia shell a mitad de la secuencia, dejando el swap a medio
|
||||||
|
terminar (visto en vivo: un primer intento así falló en silencio).
|
||||||
|
|
||||||
|
**Fix:** correr la secuencia de swap desde un contenedor *sibling*
|
||||||
|
efímero, no desde dentro de `tmux-host`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /DATA/AppData/k3d-lab-v3/tmux-host:/tmux-host-cfg \
|
||||||
|
docker:cli sh /tmux-host-cfg/swap-retry.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Como este helper solo comparte el socket de Docker (no vive *dentro*
|
||||||
|
del contenedor `tmux-host`), detener y recrear `tmux-host` no lo afecta
|
||||||
|
a él ni al proceso que lo lanzó desde el host. El script
|
||||||
|
(`swap-retry.sh`, junto a sus logs, vive en
|
||||||
|
`/DATA/AppData/k3d-lab-v3/tmux-host/` — mismo path físico que
|
||||||
|
`/media/HDD-Storage/AppData/k3d-lab-v3/tmux-host/`, ver el gotcha del
|
||||||
|
symlink más arriba) deja un log fechado por corrida en `logs/` con el
|
||||||
|
`docker ps` y un `grep` sobre `healthcheck.sh` dentro de la imagen
|
||||||
|
nueva, para verificar sin tener que adivinar si el fix realmente quedó
|
||||||
|
adentro.
|
||||||
|
|
||||||
|
**Validado en vivo dos veces (2026-09-20):** el fix de Remote Control y
|
||||||
|
el de `--continue` se desplegaron cada uno con este mismo mecanismo,
|
||||||
|
ambos confirmados por `docker ps` (imagen e `CreatedAt` nuevos) y por
|
||||||
|
`health.log` mostrando la línea de lanzamiento esperada inmediatamente
|
||||||
|
después del swap.
|
||||||
|
|
||||||
## Qué sobrevive a qué
|
## Qué sobrevive a qué
|
||||||
|
|
||||||
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? |
|
| Evento | ¿Sobrevive la sesión tmux (`lab`)? | ¿Sobrevive el login de Claude Code? | ¿Retoma la misma conversación? |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí |
|
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí | Sí — el proceso `claude` ni se entera |
|
||||||
| `docker restart tmux-host` (reinicio del NAS) | **No** — el proceso `tmux server` muere con el contenedor; `lab-session.sh` crea una sesión nueva automáticamente | Sí, vía el volumen montado en `/root/.claude` |
|
| `docker restart tmux-host` (reinicio del NAS) | **No al toque, pero se auto-recupera solo** — el proceso `tmux server` muere con el contenedor; `healthcheck.sh` la recrea en el próximo ciclo (máx. 30 min) sin intervención, o `lab-session.sh` la crea al instante si alguien se reconecta antes | Sí, vía el volumen montado en `/root/.claude` | Sí, desde el 2026-09-20 — `--continue` retoma la conversación más reciente en `WORKDIR` (ver gotcha arriba) |
|
||||||
| `docker rm` + recrear el contenedor desde cero | No | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) |
|
| `docker rm` + recrear el contenedor desde cero | No, pero igual se auto-recupera sola (mismo mecanismo de arriba) | Sí, vía el mismo volumen — no hace falta volver a autenticar ni reconfirmar el diálogo de confianza del folder (ver el mount de `claude-code-home.json`) | Sí, mismo mecanismo — validado en vivo durante el swap de imagen del 2026-09-20 |
|
||||||
|
| Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A |
|
||||||
|
| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso |
|
||||||
|
|
||||||
|
!!! warning "Incidente relacionado"
|
||||||
|
La fila de expiración del token OAuth salió del
|
||||||
|
[incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md).
|
||||||
|
El síntoma "no aparecen las sesiones remotas" llevó a eliminar el
|
||||||
|
contenedor, aunque la causa real era un token vencido. **Antes de
|
||||||
|
tocar el contenedor, mirá el pane de cada sesión.**
|
||||||
|
|
||||||
## Validación en vivo
|
## Validación en vivo
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
# ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping
|
# ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping
|
||||||
|
|
||||||
**Estado:** aceptada (implementación en curso, por sub-fases)
|
**Estado:** aceptada, con enmienda de paleta (ver §8) — sub-fases 1 a 4
|
||||||
**Fecha:** 2026-08-27
|
completas, sub-fase 5 parcial (ver §9)
|
||||||
|
**Fecha:** 2026-08-27 (enmendada 2026-09-06)
|
||||||
**Reemplaza a:** [ADR 0007](0007-identidad-visual-propia.md) (Opción 2, "Atelier cálido")
|
**Reemplaza a:** [ADR 0007](0007-identidad-visual-propia.md) (Opción 2, "Atelier cálido")
|
||||||
|
|
||||||
|
> **Nota de vigencia:** la paleta descrita en §4 ("Navy & Coral":
|
||||||
|
> marino `#122844` / coral `#D85A30` / coral claro `#F0997B`) fue la
|
||||||
|
> dirección **aprobada e implementada en las sub-fases 1-3**, pero fue
|
||||||
|
> **reemplazada el mismo día** por una paleta azul/amarillo estilo
|
||||||
|
> Walmart (rama `fix/adr0008-walmart-palette-blue-yellow`, ver §8). Los
|
||||||
|
> valores reales en producción hoy están en §8, no en §4. Se deja §4
|
||||||
|
> intacto como registro histórico de la decisión original.
|
||||||
|
|
||||||
## 1. Contexto
|
## 1. Contexto
|
||||||
|
|
||||||
Este es el tercer intento de identidad visual propia para ARI Shopping,
|
Este es el tercer intento de identidad visual propia para ARI Shopping,
|
||||||
@@ -168,3 +177,66 @@ definitivo; los alias se eliminan por completo al cerrar la sub-fase
|
|||||||
`commerce-backend`, `platform-infra`, ni CI nuevo.
|
`commerce-backend`, `platform-infra`, ni CI nuevo.
|
||||||
- `docs-portal` se actualiza con capturas/descripción del resultado
|
- `docs-portal` se actualiza con capturas/descripción del resultado
|
||||||
final una vez cada sub-fase esté validada en producción, no antes.
|
final una vez cada sub-fase esté validada en producción, no antes.
|
||||||
|
|
||||||
|
## 8. Enmienda 2026-08-27 tarde: pivote de paleta a azul/amarillo
|
||||||
|
|
||||||
|
Horas después de mergear la sub-fase 3 (product card + grid), se
|
||||||
|
mergeó `fix/adr0008-walmart-palette-blue-yellow`: la paleta "Navy &
|
||||||
|
Coral" de §4 se descartó en producción y se reemplazó por una paleta
|
||||||
|
azul/amarillo (mismo patrón "estética Walmart" ya usado como
|
||||||
|
referencia de UX en ADR 0007). Esta decisión **no quedó documentada
|
||||||
|
como enmienda en su momento** — se detecta y se registra aquí recién
|
||||||
|
el 2026-09-06, al auditar el estado real del código contra este
|
||||||
|
documento.
|
||||||
|
|
||||||
|
Valores reales en `globals.css` hoy (los nombres de token de §4 se
|
||||||
|
conservan, pero apuntan a estos valores, no a los de la tabla
|
||||||
|
original):
|
||||||
|
|
||||||
|
| Token | Valor real | Uso |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-ari-marino` | `#0055B8` (azul) | ancla: header, footer, fondo de hero, botón de búsqueda |
|
||||||
|
| `--color-ari-coral` | `#0055B8` (azul, mismo valor que marino) | CTA "Agregar al carrito", precio destacado |
|
||||||
|
| `--color-ari-coral-claro` | `#FFC220` (amarillo) | badges de oferta/urgencia, acento del isotipo, botón "yellow" del hero |
|
||||||
|
|
||||||
|
Nota: `ari-coral` y `ari-marino` son el mismo azul (`#0055B8`) — a
|
||||||
|
diferencia de la especificación original de §4, donde marino y coral
|
||||||
|
eran colores distintos. Cualquier lectura de este ADR para decisiones
|
||||||
|
de diseño futuras debe partir de esta tabla, no de §4.
|
||||||
|
|
||||||
|
El isotipo (mariposa de 4 elipses, §4) tampoco se implementó como se
|
||||||
|
describe: el logo vigente es `logo-ari-circular.svg` (rama
|
||||||
|
`adr0008-logo-circular-header`), un lockup circular distinto al
|
||||||
|
descrito en la tabla de coordenadas de §4. `logo-ari.svg` (rectangular,
|
||||||
|
el que sí sigue la spec original) quedó sin usarse en el header.
|
||||||
|
|
||||||
|
## 9. Estado real por sub-fase (auditado 2026-09-06)
|
||||||
|
|
||||||
|
Auditoría contra el código en `main`, no contra el plan de §6.
|
||||||
|
|
||||||
|
| # | Sub-fase | Estado | Evidencia |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Tokens base + logo | ✅ completa (con la paleta enmendada en §8, no la de §4) | `globals.css`, `logo-ari.svg` inicial, luego reemplazado por circular en sub-fase posterior |
|
||||||
|
| 2 | Header + footer (incl. limpieza de §5) | ✅ completa | `navbar.tsx`, restos arcoíris (gradiente ámbar/lima, línea cian/violeta/fucsia de §5) eliminados |
|
||||||
|
| 3 | Product card + grid + badges | ✅ completa | `product-card.tsx` en tokens `ari-*` |
|
||||||
|
| 4 | Home (hero, tiles, secciones) | ✅ completa | `hero-carousel.tsx`, `category-tiles.tsx` (varias iteraciones: pastel → scrim → foto flotante → foto más grande, todas mergeadas) |
|
||||||
|
| 5 | Página de producto + drawers de carrito/wishlist | ⚠️ **parcial** | `product-purchase.tsx`/página de producto sí migraron (rama `adr0008-product-purchase-mobile-y-badge`). **`cart-drawer.tsx` y `wishlist-drawer.tsx` no tienen ningún token `ari-*` — nunca se tocaron.** No hay rama mergeada que los mencione. |
|
||||||
|
|
||||||
|
**Deuda abierta, no asumir resuelta:**
|
||||||
|
|
||||||
|
- Los alias de compatibilidad de §6 (`ari-ciruela`, `ari-cobre`,
|
||||||
|
`ari-carbon`, `ari-carbon-oscuro`, `ari-terracota-oscuro`,
|
||||||
|
`ari-hueso`) **siguen en uso real en `button.tsx`**
|
||||||
|
(`focus-visible:ring-ari-cobre/40`, `bg-ari-ciruela`,
|
||||||
|
`hover:border-ari-cobre`) — la condición de cierre de §6 ("los alias
|
||||||
|
se eliminan por completo al cerrar la sub-fase 5") no se cumplió.
|
||||||
|
Confirma que la sub-fase 5 no está cerrada, más allá de los drawers.
|
||||||
|
- Ramas mergeadas fuera del plan original de 5 sub-fases de §6, sin
|
||||||
|
enmienda que las registre: `adr0008-megamenu-mobile-overflow`,
|
||||||
|
`adr0008-club-ari-y-novedades`, `adr0008-club-ari-imagen-azul`. Son
|
||||||
|
trabajo real en producción, pero expanden el alcance de este ADR sin
|
||||||
|
quedar documentado como tal.
|
||||||
|
- `docs-portal` (este documento) es la primera actualización desde el
|
||||||
|
cierre de la sub-fase 3 — la condición de §7 ("se actualiza con
|
||||||
|
capturas/descripción... una vez cada sub-fase esté validada") no se
|
||||||
|
vino cumpliendo sub-fase por sub-fase como se planeó.
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
|
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
|
||||||
|
|
||||||
**Estado:** aceptada — proveedor de pago decidido (PayU, §3);
|
**Estado:** aceptada — proveedor de pago decidido (PayU, §3); la
|
||||||
implementación real (credenciales, shipping options, código) pendiente
|
implementación real terminó usando ePayco en vez de PayU (PayU LatAm
|
||||||
|
quedó inviable, Rapyd se evaluó y se descartó sin código) y sumó
|
||||||
|
EnvíoClick para envío calculado — ver [ADR 0010](./0010-pago-envio-reales-y-storefront.md)
|
||||||
|
para el pivote completo y el estado final desplegado
|
||||||
**Fecha:** 2026-08-28
|
**Fecha:** 2026-08-28
|
||||||
|
|
||||||
## Contexto
|
## Contexto
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
# ADR 0010 — Pago (ePayco PSE) y envío (EnvíoClick) reales; banner y buscador del storefront
|
||||||
|
|
||||||
|
**Estado:** aceptada — ambas integraciones desplegadas y verificadas en vivo (sandbox de cada proveedor); banner dinámico y buscador rediseñado desplegados
|
||||||
|
**Fecha:** 2026-09-09
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El ADR 0009 dejó **PayU** decidido como proveedor de pago de negocio y
|
||||||
|
documentó la necesidad de un módulo custom (`AbstractPaymentProvider`)
|
||||||
|
porque ningún plugin comunitario de Medusa sirve para la API real de
|
||||||
|
PayU LatAm. Entre esa decisión y la implementación real pasaron dos
|
||||||
|
correcciones más, ninguna prevista en el ADR original — este documento
|
||||||
|
cierra esa cadena y agrega la segunda mitad de "checkout real" que el
|
||||||
|
0009 dejó pendiente: el envío (EnvíoClick), más dos mejoras de UI del
|
||||||
|
storefront relacionadas (banner dinámico, buscador) pedidas una vez que
|
||||||
|
ambas integraciones ya estaban en producción.
|
||||||
|
|
||||||
|
## 1. Pago — de PayU a Rapyd a ePayco
|
||||||
|
|
||||||
|
### 1.1 PayU LatAm quedó inviable: Rapyd compró ese negocio
|
||||||
|
|
||||||
|
Al intentar registrar la cuenta de comercio en `developers.payulatam.com`
|
||||||
|
(2026-08-29), el registro real redirige a **Rapyd** — confirmado en vivo
|
||||||
|
contra `board.rapyd.net` con capturas de pantalla del propio usuario, no
|
||||||
|
solo documentación. Investigación confirmó la causa: **Rapyd adquirió el
|
||||||
|
negocio de PayU LatAm/África en 2023.** PayU LatAm como entidad separada
|
||||||
|
con la API documentada en el ADR 0009 (`apiLogin`/`apiKey`/`accountId`)
|
||||||
|
ya no es la vía de registro real para un comercio nuevo.
|
||||||
|
|
||||||
|
Se investigó y planificó por completo la alternativa Rapyd (API REST
|
||||||
|
moderna, firma HMAC-SHA256, endpoint `/v1/payments` — arquitectura
|
||||||
|
totalmente distinta a la de PayU LatAm clásico, no una migración menor
|
||||||
|
de credenciales) pero **nunca se escribió código**: antes de implementar,
|
||||||
|
el usuario planteó ePayco como alternativa y, al evaluarla, resultó
|
||||||
|
preferible por costo/encaje — Rapyd quedó descartado en la etapa de plan,
|
||||||
|
no de código.
|
||||||
|
|
||||||
|
### 1.2 Decisión final: ePayco
|
||||||
|
|
||||||
|
**Motivo de negocio:** ePayco es 100% propiedad de Davivienda desde 2024
|
||||||
|
— mejor tarifa PSE dado que Davivienda ya es uno de los bancos
|
||||||
|
disponibles del comercio (mismo dato de negocio del ADR 0009 §3:
|
||||||
|
Davivienda, Lulo Bank, Banco de Bogotá).
|
||||||
|
|
||||||
|
**Hallazgo técnico no obvio, verificado en vivo, no en documentación:**
|
||||||
|
ePayco tiene dos productos de integración distintos bajo la misma marca:
|
||||||
|
|
||||||
|
- Un producto nuevo tipo "Apify"/Web-Checkout (widget alojado).
|
||||||
|
- La integración real de PSE que efectivamente se usó: la API
|
||||||
|
"restpagos" / `secure.payco.co`, **más antigua**, que combina login con
|
||||||
|
bearer token **más cifrado AES-CBC campo por campo** usando la llave
|
||||||
|
privada del comercio como clave AES con IV en cero — un protocolo poco
|
||||||
|
común que no vale la pena reimplementar a mano. Se optó por depender
|
||||||
|
del paquete oficial `epayco-sdk-node` en vez de escribir el cliente
|
||||||
|
HTTP propio (a diferencia de EnvíoClick, ver §2).
|
||||||
|
|
||||||
|
Nombres de método/campo reales del SDK, corregidos contra su código
|
||||||
|
fuente instalado (no contra su propia documentación pública, que estaba
|
||||||
|
desactualizada respecto al paquete):
|
||||||
|
|
||||||
|
- Lista de bancos PSE: `getBanks()` (la doc pública sugiere `pseBank()`,
|
||||||
|
que no existe en el SDK real).
|
||||||
|
- Confirmación de método: campo `method_confirmation` (la doc pública
|
||||||
|
sugiere `metodoconfirmacion`).
|
||||||
|
|
||||||
|
**Implementación** (`commerce-backend/src/modules/epayco-pse/`,
|
||||||
|
`src/lib/epayco.ts`): módulo de pago custom
|
||||||
|
(`AbstractPaymentProvider`), estado `pending_authorization` tras iniciar
|
||||||
|
el pago (mecanismo documentado de Medusa para métodos asíncronos —
|
||||||
|
permite que el carrito complete en una orden "esperando confirmación" sin
|
||||||
|
bloquear sobre la respuesta del banco), webhook nativo de Medusa en
|
||||||
|
`/hooks/payment/epayco_pse` (sin subscriber custom — el
|
||||||
|
`payment-webhook` + `process-payment-workflow` incluidos en el core ya
|
||||||
|
completan la orden automáticamente al recibir la notificación).
|
||||||
|
|
||||||
|
**Verificado en vivo (2026-09):**
|
||||||
|
- Credenciales reales del comercio (`P_CUST_ID_CLIENTE`, `P_KEY`,
|
||||||
|
`PUBLIC_KEY`, `PRIVATE_KEY`) cargadas en el secret real del cluster.
|
||||||
|
- Llamada real a `getBanks()` contra la API de ePayco devolvió el listado
|
||||||
|
real de bancos PSE en modo sandbox.
|
||||||
|
- El webhook `/hooks/payment/epayco_pse` es alcanzable desde afuera:
|
||||||
|
`POST` externo contra `https://commerce.cruzcloud.net/hooks/payment/epayco_pse`
|
||||||
|
responde `200 OK` (la firma inválida se maneja puertas adentro sin
|
||||||
|
romper la respuesta HTTP) — cierra el riesgo de red que el ADR 0009 §2
|
||||||
|
había señalado como pendiente de validar (mismo patrón de incidente ya
|
||||||
|
documentado para otro servicio,
|
||||||
|
`incidente-cloudflared-504-quic-2026-08.md`).
|
||||||
|
|
||||||
|
**Limitaciones conocidas y aceptadas, no bloqueantes:**
|
||||||
|
- `refundPayment` tira un error explícito de "no implementado" — el
|
||||||
|
endpoint real de reembolso nunca se verificó en vivo, se prefirió
|
||||||
|
fallar ruidosamente a fingir éxito.
|
||||||
|
- El nombre exacto del campo de la URL de redirección del banco en la
|
||||||
|
respuesta de `bank.create()` se maneja de forma defensiva, probando
|
||||||
|
varios nombres candidatos con un error explícito y el payload completo
|
||||||
|
si ninguno matchea — **todavía no se probó con un pago PSE real de
|
||||||
|
principio a fin** (solo `getBanks()` está confirmado en vivo). Ver §3
|
||||||
|
para cómo cerrar esto desde el storefront real.
|
||||||
|
- El comercio está en modo **sandbox** de ePayco (`EPAYCO_ENV: sandbox`
|
||||||
|
en el ConfigMap) — no se ha movido dinero real todavía.
|
||||||
|
|
||||||
|
## 2. Envío — EnvíoClick reemplaza la tarifa fija
|
||||||
|
|
||||||
|
El ADR 0009 (nota de implementación) dejó una shipping option con tarifa
|
||||||
|
fija de $15.000 COP como **placeholder de negocio**, anotando
|
||||||
|
explícitamente que un cálculo real por peso/volumen era trabajo futuro
|
||||||
|
— "eso es justamente lo que EnvíoClick resolvería más adelante". Este
|
||||||
|
ADR cierra ese punto.
|
||||||
|
|
||||||
|
### Hallazgo de negocio, no técnico, que condiciona la precisión real
|
||||||
|
|
||||||
|
**Ningún producto del catálogo tiene peso ni dimensiones cargados**
|
||||||
|
(confirmado contra la base real: 18 productos activos, 0 con `weight`,
|
||||||
|
0 con `length`). Sin este dato, cualquier cotización real de EnvíoClick
|
||||||
|
no puede ser precisa. Se aceptó como **tarea de datos pendiente, no de
|
||||||
|
código** — la integración usa un paquete de fallback conservador (1 kg,
|
||||||
|
20×20×20 cm) mientras no haya datos reales por producto, documentado en
|
||||||
|
`src/modules/envioclick-fulfillment/types.ts` (`DEFAULT_PACKAGE`).
|
||||||
|
|
||||||
|
### API real, corregida contra la fuente primaria, no contra un plugin de referencia desactualizado
|
||||||
|
|
||||||
|
Un plugin de WordPress usado como primera referencia resultó ser para la
|
||||||
|
**API v1** de EnvíoClick, ya obsoleta. La doc real
|
||||||
|
(`apidoc.envioclickpro.com.co`, generada con apiDoc — se bajó y leyó
|
||||||
|
directo `api_data.json`/`api_project.json`, ya que la SPA no renderizaba
|
||||||
|
sin sesión) reveló diferencias reales, confirmadas con una llamada real
|
||||||
|
exitosa:
|
||||||
|
|
||||||
|
| | Plugin de referencia (v1, desactualizado) | API real (v2, confirmada en vivo) |
|
||||||
|
|---|---|---|
|
||||||
|
| Base URL | `api.envioclickpro.com` | `https://api.envioclickpro.com.co/api/v2` |
|
||||||
|
| Header de auth | `AuthorizationKey: <key>` | `Authorization: <key>` (sin prefijo) |
|
||||||
|
|
||||||
|
`POST /quotation` devolvió en vivo tarifas reales de PIBOX, COORDINADORA,
|
||||||
|
ENVIA, RAPPI y TCC para una cotización de prueba real — se toma
|
||||||
|
`flete + minimumInsurance` (seguro mínimo obligatorio, hay que sumarlo al
|
||||||
|
flete para el precio real a cobrar) más barato del array `data.rates[]`,
|
||||||
|
mismo criterio de "más económico" que ya usaba el plugin de referencia.
|
||||||
|
|
||||||
|
### Hallazgo crítico de Medusa, evitado antes de romper nada (no un incidente real)
|
||||||
|
|
||||||
|
Leyendo directo `@medusajs/utils/dist/common/define-config.js` (no
|
||||||
|
documentación): `defineConfig()` ya registra por defecto
|
||||||
|
`Modules.FULFILLMENT` con el provider `manual_manual`, aunque
|
||||||
|
`medusa-config.ts` no lo mencione explícitamente. `transformModules()`
|
||||||
|
indexa por `serviceName` y el último bloque que se declara **pisa**, no
|
||||||
|
suma, al anterior. Agregar un bloque de fulfillment propio sin incluir
|
||||||
|
también `@medusajs/medusa/fulfillment-manual` adentro habría roto el
|
||||||
|
fulfillment manual existente en silencio — mismo tipo de riesgo que casi
|
||||||
|
pasó con `payment` en el ADR 0009, evitado leyendo el código fuente real
|
||||||
|
antes de escribir el bloque nuevo (ambos providers conviven hoy en el
|
||||||
|
mismo bloque de `medusa-config.ts`).
|
||||||
|
|
||||||
|
**Implementación** (`commerce-backend/src/modules/envioclick-fulfillment/`,
|
||||||
|
`src/lib/envioclick.ts`): `AbstractFulfillmentProviderService`,
|
||||||
|
`calculatePrice()` invocado en vivo cada vez que el carrito refresca
|
||||||
|
shipping options (Medusa advierte que si este método falla, **bloquea el
|
||||||
|
checkout** — el fallback conservador de arriba existe también por esto,
|
||||||
|
no solo por falta de datos). `createFulfillment()` genera la guía real vía
|
||||||
|
`/shipment` (o `/shipment_sandbox` en modo prueba —
|
||||||
|
**el único endpoint con sandbox real**, la cotización siempre usa
|
||||||
|
tarifas reales). Sin cambios en el storefront: `GET /store/shipping-options`
|
||||||
|
nativo de Medusa ya invocaba `calculatePrice()` automáticamente para
|
||||||
|
cualquier shipping option `price_type: "calculated"`.
|
||||||
|
|
||||||
|
**Verificado en vivo (2026-09):**
|
||||||
|
- Shipping option "Envío nacional estándar" migrada de `manual_manual`/
|
||||||
|
tarifa fija a `envioclick_standard`/`calculated` vía Admin API (con
|
||||||
|
usuario admin temporal, eliminado después de usarlo).
|
||||||
|
- Un `400` inicial (`"Providers (envioclick_standard) are not enabled
|
||||||
|
for the service location"`) se resolvió vinculando el provider a la
|
||||||
|
stock location (`POST /admin/stock-locations/:id/fulfillment-providers`)
|
||||||
|
— Medusa exige esto además del bloque de módulo.
|
||||||
|
- Carrito de prueba real con dirección → `shipping_total: 8941` COP,
|
||||||
|
tarifa real de EnvíoClick, no el fijo de $15.000.
|
||||||
|
- Datos reales de la bodega de origen cargados (dirección `Cra 100
|
||||||
|
#42-65`, contacto `cristiancruz29@outlook.com` / `3014750823`) tanto en
|
||||||
|
el ConfigMap (`ENVIOCLICK_ORIGIN_*`) como en el registro real de la
|
||||||
|
stock location en Medusa (antes: "Sin dirección física registrada").
|
||||||
|
- El comercio está en modo **sandbox** de EnvíoClick
|
||||||
|
(`ENVIOCLICK_SANDBOX: "true"`) — las guías que se generan son de
|
||||||
|
prueba, sin costo; ninguna cotización es de prueba (no existe sandbox
|
||||||
|
de cotización).
|
||||||
|
|
||||||
|
**Limitaciones conocidas y aceptadas, no bloqueantes:**
|
||||||
|
- `createReturnFulfillment` tira "no implementado" — devoluciones fuera
|
||||||
|
de alcance.
|
||||||
|
- Cancelación de guía real (`POST cancellation/batch/order`, hasta 50 por
|
||||||
|
lote, usando los `idOrder` que devuelve `/shipment`) confirmada como
|
||||||
|
existente en la API pero **no implementada en el módulo** — se
|
||||||
|
descubrió durante la investigación, no se integró por no ser parte del
|
||||||
|
flujo de checkout mínimo.
|
||||||
|
- Solo EnvíoClick como provider — no hay selección de múltiples
|
||||||
|
couriers por el comprador (v1 siempre toma el más barato).
|
||||||
|
- Ningún producto tiene peso/dimensiones reales — ver arriba.
|
||||||
|
|
||||||
|
## 3. Storefront — banner dinámico y buscador
|
||||||
|
|
||||||
|
Con ambas integraciones ya en producción, se atendieron dos pedidos de
|
||||||
|
UI del storefront relacionados con la experiencia de compra real:
|
||||||
|
|
||||||
|
### 3.1 Banner dinámico (`components/home/hero-carousel.tsx`)
|
||||||
|
|
||||||
|
Antes: tres slides con copy e imágenes fijas, hardcodeadas en el
|
||||||
|
componente, sin relación con el catálogo real. Ahora: `HomePage`
|
||||||
|
(`app/page.tsx`, que ya hace `getProducts()` del backend real) le pasa al
|
||||||
|
carrusel los productos marcados como `featured` en el admin (o, si no hay
|
||||||
|
ninguno, los mismos "tesoros" —nuevos o destacados— que ya usa la sección
|
||||||
|
de abajo, para no duplicar el criterio de selección); el carrusel arma
|
||||||
|
cada slide con la imagen, nombre, precio (`priceLabel`) y link reales del
|
||||||
|
producto, calculando el badge de descuento (`% OFF`) cuando
|
||||||
|
`compareAtPrice` existe. Si la tienda no tuviera ningún producto activo
|
||||||
|
(caso borde), cae a un slide estático de respaldo para no romper la
|
||||||
|
página. Autoplay, dots de paginación y animación quedan igual — solo
|
||||||
|
cambia el origen de los datos.
|
||||||
|
|
||||||
|
### 3.2 Buscador (`components/layout/navbar.tsx`)
|
||||||
|
|
||||||
|
El header en mobile competía el buscador con el logo y los tres iconos
|
||||||
|
de acción en una sola fila de grid (`grid-cols-[auto_1fr_auto]`),
|
||||||
|
quedando visualmente recortado — reportado por el usuario junto a una
|
||||||
|
captura de referencia del header mobile de Best Buy (fila de iconos
|
||||||
|
arriba, buscador a todo el ancho debajo, con micrófono y escaneo QR
|
||||||
|
inline).
|
||||||
|
|
||||||
|
**Decisión:** adoptar el mismo patrón de dos filas en mobile/tablet
|
||||||
|
(logo + iconos arriba, buscador a todo el ancho debajo) pero **sin** los
|
||||||
|
íconos de micrófono/QR — no hay búsqueda por voz ni por código de barras
|
||||||
|
implementada, y un ícono sin función real confunde más de lo que ayuda.
|
||||||
|
En desktop (`lg:` y arriba) se mantuvo la fila única original del grid
|
||||||
|
`[auto_1fr_auto]`: ahí ya sobraba espacio horizontal y partirla en dos
|
||||||
|
solo alargaría el header sin necesidad. Sin cambios en la lógica de
|
||||||
|
`PredictiveSearch` (autocompletado, debounce, navegación) — solo se
|
||||||
|
reubicó su contenedor según el breakpoint.
|
||||||
|
|
||||||
|
## Opciones consideradas (pago)
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| PayU LatAm (ADR 0009) | Ya decidido, credenciales bien documentadas | Registro real redirige a Rapyd — la entidad "PayU LatAm" independiente ya no existe para un comercio nuevo |
|
||||||
|
| Rapyd | Dueño real del negocio ex-PayU LatAm, API moderna y bien documentada | API completamente distinta (HMAC, REST moderno) — no es una migración de credenciales, es una integración nueva desde cero; descartada en fase de plan por costo/encaje frente a ePayco |
|
||||||
|
| **ePayco** — elegido | 100% Davivienda desde 2024, mejor tarifa PSE con el banco ya disponible del comercio; SDK oficial cubre el protocolo inusual (login + cifrado AES por campo) | Dos productos bajo la misma marca (Apify vs. la API real usada) fáciles de confundir; doc pública desactualizada respecto al SDK real; reembolsos no implementados |
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Checkout real de punta a punta (pago + envío calculado) queda
|
||||||
|
desplegado y verificado con navegador real hasta la redirección al
|
||||||
|
banco de PSE (ver nota de implementación 2026-09-10 abajo), pero
|
||||||
|
**ambos proveedores siguen en modo sandbox** — no se ha completado ni
|
||||||
|
un pago real ni un envío real todavía, y el login bancario de PSE
|
||||||
|
tampoco se completó (falta credencial de prueba). Ver checklist
|
||||||
|
ampliado abajo.
|
||||||
|
- La precisión de la tarifa de envío depende de cargar peso/dimensiones
|
||||||
|
reales por producto — deuda de datos explícita, no de código; candidato
|
||||||
|
natural a extender el importador CSV existente con columnas nuevas.
|
||||||
|
- Reembolsos (ePayco) y devoluciones/cancelación de guía (EnvíoClick)
|
||||||
|
quedan fuera de alcance — fallan explícitamente en vez de fingir éxito.
|
||||||
|
- Revertir el pago implica deshabilitar el provider `epayco_pse` en la
|
||||||
|
región y volver al placeholder anterior; revertir el envío implica
|
||||||
|
volver la shipping option a `manual_manual`/tarifa fija — ninguna de
|
||||||
|
las dos requiere cambios de esquema de datos para revertirse.
|
||||||
|
- El banner y el buscador no introducen dependencias nuevas ni cambian
|
||||||
|
ninguna ruta de API — son cambios de storefront puros, reversibles
|
||||||
|
cambiando los dos componentes de vuelta a su versión anterior.
|
||||||
|
|
||||||
|
## Checklist de pruebas reales desde el storefront (pendiente, no ejecutado en este ADR)
|
||||||
|
|
||||||
|
Todo lo de arriba se verificó primero a nivel API/backend con llamadas
|
||||||
|
directas o carritos de prueba armados por API, sin manejar el
|
||||||
|
storefront real como lo haría un comprador. Esa parte del checklist se
|
||||||
|
cerró el 2026-09-10 (ver nota de implementación abajo) usando un
|
||||||
|
navegador real de verdad, no curl.
|
||||||
|
|
||||||
|
### RESUELTO (2026-09-10) — navegador real vía Docker en la NAS
|
||||||
|
|
||||||
|
El shell donde corren los comandos de esta sesión tiene su propia red
|
||||||
|
restringida para `npm` (un proxy interno que rompe algunas descargas de
|
||||||
|
paquetes — no relacionado con la red real del lab). **Docker en la NAS
|
||||||
|
sí sale a internet real, sin ese filtro** — confirmado en vivo
|
||||||
|
(`docker pull` normal, un contenedor de Node golpeando
|
||||||
|
`registry.npmjs.org` sin problema). Se armó un harness reutilizable con
|
||||||
|
la imagen oficial `mcr.microsoft.com/playwright:v1.51.1-jammy`
|
||||||
|
(Chromium real) corrida puntualmente contra `shop.cruzcloud.net`,
|
||||||
|
guardado en `.browser-scratch/` en la raíz del lab (`checks.mjs` +
|
||||||
|
`README.md` con el comando exacto para reactivarlo — no hace falta
|
||||||
|
ningún contenedor corriendo de forma persistente, la imagen y las
|
||||||
|
dependencias ya quedan cacheadas localmente en la NAS).
|
||||||
|
|
||||||
|
**Hallazgo no obvio:** el daemon de Docker de la NAS ve un filesystem
|
||||||
|
distinto al que ve el shell de esta sesión (que corre en su propio
|
||||||
|
sandbox, aunque monte los mismos repos git) — un volumen `-v` apuntando
|
||||||
|
a un path de `/tmp` del shell no tira error pero monta un directorio
|
||||||
|
vacío del lado del contenedor. Hay que usar un path bajo
|
||||||
|
`/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/` (montaje real
|
||||||
|
compartido) para que el contenedor vea los archivos de verdad.
|
||||||
|
|
||||||
|
**Resultados, con capturas reales:**
|
||||||
|
|
||||||
|
1. **Pago PSE completo — FUNCIONA.** Carrito → dirección Bogotá →
|
||||||
|
envío calculado → banco PSE (sandbox) → **la redirección real
|
||||||
|
funcionó**: el nombre de campo manejado a la defensiva en
|
||||||
|
`bank.create()`/`initiatePayment` matcheó al primer intento, y el
|
||||||
|
navegador llegó a la página real de autorización de PSE
|
||||||
|
(`registro.desarrollo.pse.com.co`, ambiente de desarrollo/sandbox
|
||||||
|
de PSE Colombia). No se completó el login bancario (requiere
|
||||||
|
credenciales de prueba específicas de PSE que no se tenían a mano) —
|
||||||
|
sigue pendiente confirmar el webhook con un pago sandbox completado
|
||||||
|
de principio a fin (ver checklist nuevo abajo).
|
||||||
|
2. **Envío calculado real — FUNCIONA.** El paso de pago mostró
|
||||||
|
`Envío $8.941` (mismo valor ya visto en la verificación por API de
|
||||||
|
la sección anterior) en vez del fijo de $15.000, para una dirección
|
||||||
|
real de Bogotá.
|
||||||
|
3. **Banner dinámico — funciona, pero destapó un bug real:** un
|
||||||
|
producto marcado `featured: true` pero **sin stock**
|
||||||
|
(`available: false`, "Toy Story 5 - Rex") salía primero en el
|
||||||
|
carrusel con botón "Comprar ahora" hacia una página donde en
|
||||||
|
realidad no se puede comprar. Corregido en el mismo commit que este
|
||||||
|
hallazgo (filtro adicional `&& product.available` en
|
||||||
|
`app/page.tsx`).
|
||||||
|
4. **Buscador mobile — funciona.** El buscador ya no se ve recortado,
|
||||||
|
y el autocompletado despliega bien debajo de la fila nueva a todo el
|
||||||
|
ancho (probado con el viewport de un iPhone 13 real, no devtools).
|
||||||
|
5. **Bug adicional encontrado, no buscado:** el sandbox real de ePayco
|
||||||
|
(`bank.getBanks()`) devuelve un primer elemento placeholder
|
||||||
|
(`bankCode: "0"`, `"A continuación seleccione su banco..."` con el
|
||||||
|
texto mal decodificado — unicode escapado literal) mezclado con los
|
||||||
|
bancos reales, mostrado como una opción más, seleccionable, en el
|
||||||
|
`<select>` del checkout. Ningún banco PSE real tiene código "0" —
|
||||||
|
se filtra explícitamente en `pse-banks/route.ts`.
|
||||||
|
|
||||||
|
Ampliado 2026-09-10 con el resto de la superficie real del storefront
|
||||||
|
(4 páginas + `components/{account,cart,catalog,checkout,home,layout,
|
||||||
|
product,search,wishlist}`), agrupado por riesgo, y ejecutado el mismo
|
||||||
|
día contra `shop.cruzcloud.net` con el harness de `.browser-scratch/`.
|
||||||
|
|
||||||
|
### RESUELTO (2026-09-11) — segunda tanda con navegador real
|
||||||
|
|
||||||
|
Resultados reales, uno por uno (código de cada prueba en
|
||||||
|
`checks.mjs`):
|
||||||
|
|
||||||
|
- **`/checkout` con carrito vacío (ítem 9) — OK.** Muestra "Tu carrito
|
||||||
|
está vacío" con link a `/catalog`, no rompe.
|
||||||
|
- **Fallback "Confirmar por WhatsApp" (ítem 10) — OK.** Abre
|
||||||
|
`api.whatsapp.com/send?phone=...` en pestaña nueva con el mensaje del
|
||||||
|
pedido armado correctamente.
|
||||||
|
- **Filtros de `/catalog` (ítem 11) — OK.** Un filtro de categoría real
|
||||||
|
("Figuras de acción") redujo los productos mostrados de 18 a 4. Nota:
|
||||||
|
estos filtros (categoría/marca/edad dentro de la barra lateral) son
|
||||||
|
**estado de cliente, no reflejado en la URL** — distinto de los
|
||||||
|
filtros por query param (`?category=...`) que sí usa la navegación
|
||||||
|
del header. Ninguno de los dos es un bug, son dos mecanismos
|
||||||
|
distintos coexistiendo; queda como nota para no confundirlos.
|
||||||
|
- **Búsqueda sin resultados (ítem 12) — BUG real encontrado y
|
||||||
|
corregido.** El dropdown de autocompletado no mostraba absolutamente
|
||||||
|
nada (ni "Buscando…" ni ningún mensaje) cuando una búsqueda de 2+
|
||||||
|
caracteres no tenía coincidencias — el usuario se quedaba sin
|
||||||
|
feedback de que su búsqueda había corrido. Corregido en
|
||||||
|
`predictive-search.tsx`: ahora muestra "No encontramos resultados
|
||||||
|
para…" con el botón de buscar en todo el catálogo.
|
||||||
|
- **Slug de producto inválido (ítem 14) — hallazgo, no corregido
|
||||||
|
todavía.** La página sí muestra el contenido correcto de "Este tesoro
|
||||||
|
no existe" (el código ya llama a `notFound()` de
|
||||||
|
`next/navigation`, como corresponde), **pero la respuesta HTTP real
|
||||||
|
es `200`, no `404`** — confirmado independientemente con `curl`, no
|
||||||
|
es un artefacto de Playwright. No se investigó a fondo en qué capa se
|
||||||
|
pierde el status real (Next.js standalone vs. alguna capa de
|
||||||
|
Cloudflare/NPM/Traefik en el medio, ver
|
||||||
|
`incidente-cloudflared-504-quic-2026-08.md` para precedente de
|
||||||
|
problemas de red en esa cadena) — bajo impacto en la experiencia del
|
||||||
|
usuario (el contenido es correcto), pero sí afecta SEO/crawlers.
|
||||||
|
Queda como pendiente de investigar, no de corregir a ciegas.
|
||||||
|
- **Zoom interactivo del producto (ítem 15) — OK.** Hover-zoom y el
|
||||||
|
modal de click-to-zoom siguen funcionando igual tras los cambios
|
||||||
|
recientes al storefront.
|
||||||
|
- **Wishlist/favoritos (ítem 17) — OK.** Agregar a favoritos +
|
||||||
|
recargar la página: persiste correctamente vía `localStorage`.
|
||||||
|
- **Otros viewports, tablet y desktop grande (ítem 19) — OK.**
|
||||||
|
Capturas guardadas, sin problemas de layout visibles en el banner ni
|
||||||
|
el header en esos anchos.
|
||||||
|
- **Página 404 genérica (ítem 20) — OK.** Una ruta que no existe cae en
|
||||||
|
`not-found.tsx` con status HTTP `404` real (a diferencia del caso de
|
||||||
|
producto inválido arriba — la diferencia de comportamiento entre
|
||||||
|
ambos casos es justamente lo que quedó como pendiente de investigar).
|
||||||
|
- **Accesibilidad básica (ítem 21) — OK, parcial.** El buscador es
|
||||||
|
alcanzable navegando solo con `Tab` desde el body, sin mouse. Falta
|
||||||
|
probar el resto del flujo de checkout por teclado.
|
||||||
|
- **Registro/login con email y contraseña (parte del ítem 16) — OK.**
|
||||||
|
Crear cuenta con nombre/apellido/email/contraseña funciona y deja la
|
||||||
|
sesión iniciada. La parte de Google OAuth sigue sin probarse (necesita
|
||||||
|
una cuenta de Google real, no algo que se pueda automatizar sin
|
||||||
|
credenciales).
|
||||||
|
|
||||||
|
### Pendiente — lo que queda sin probar
|
||||||
|
|
||||||
|
3. **Resiliencia del fallback de envío:** forzar una dirección o
|
||||||
|
producto que haga fallar la cotización real de EnvíoClick (ej. un
|
||||||
|
DANE code inválido) y confirmar que el checkout sigue avanzando con
|
||||||
|
el precio de respaldo ($15.000) en vez de romperse — comportamiento
|
||||||
|
documentado en `calculatePrice()`, nunca ejercitado con un fallo
|
||||||
|
real.
|
||||||
|
6. **Webhook de ePayco con un pago real de sandbox completado:**
|
||||||
|
conseguir credenciales de prueba reales de PSE (persona/clave de
|
||||||
|
algún banco del ambiente `desarrollo`), completar el login bancario
|
||||||
|
de punta a punta, y confirmar que el webhook mueve la orden a
|
||||||
|
completada sin intervención manual.
|
||||||
|
7. **Pago cancelado o rechazado en el banco PSE:** confirmar que la
|
||||||
|
orden no queda en un estado ambiguo ("colgada" en
|
||||||
|
`pending_authorization` para siempre).
|
||||||
|
8. **Persona jurídica + NIT** en el formulario de PSE — solo se probó
|
||||||
|
persona natural con cédula.
|
||||||
|
13. **Selección de variantes** (cuando un producto tiene más de una) y
|
||||||
|
que el precio/disponibilidad mostrado cambie según la variante
|
||||||
|
elegida — ningún producto probado hasta ahora tenía más de una
|
||||||
|
variante real.
|
||||||
|
14b. **Investigar por qué `/product/<slug-inválido>` responde HTTP 200**
|
||||||
|
en vez de 404 pese a llamar `notFound()` correctamente (ver
|
||||||
|
hallazgo arriba) — identificar la capa responsable antes de tocar
|
||||||
|
código.
|
||||||
|
16b. **Login con Google (OAuth completo)** incluyendo
|
||||||
|
`/auth/google/callback` — necesita una cuenta de Google real de
|
||||||
|
prueba.
|
||||||
|
18. **Bug conocido de Firefox Android** (ver memoria de proyecto,
|
||||||
|
`known-bug-firefox-android-layout`) — Playwright puede emular
|
||||||
|
Android/Chrome pero no Firefox; hace falta `playwright-firefox` o
|
||||||
|
un dispositivo real.
|
||||||
|
21b. **Accesibilidad del checkout completo** por teclado (no solo el
|
||||||
|
buscador) — tab order y foco visible en los 3 pasos.
|
||||||
|
22. **Importación manual de imágenes no se reflejaba en el storefront —
|
||||||
|
RESUELTO 2026-09-20, con un giro real durante la verificación.**
|
||||||
|
Reportado por el usuario 2026-09-20. Causa raíz #1 (caché
|
||||||
|
multi-pod): `medusaFetch()` en `lib/headless/providers/medusa.ts`
|
||||||
|
usaba `next:{revalidate:60,tags:["medusa-products"]}`, caché en
|
||||||
|
memoria por-pod que sobrevivía a `force-dynamic`. Con
|
||||||
|
`frontend-deploy` en 2 réplicas sin cacheHandler compartido, el POST
|
||||||
|
de `/api/revalidate` del subscriber de Medusa solo llegaba a uno de
|
||||||
|
los dos pods — el otro seguía sirviendo datos viejos hasta que el
|
||||||
|
TTL expirara solo. Una edición puntual + revisar al toque tenía
|
||||||
|
~50% de chance de pegarle al pod stale; una importación CSV (mucho
|
||||||
|
más lenta) le daba tiempo de sobra al TTL para expirar en ambos.
|
||||||
|
**Fix #1:** se sacó el `next.revalidate`/`tags` del fetch.
|
||||||
|
|
||||||
|
Verificado en vivo por el usuario con una edición real (agregó
|
||||||
|
varias fotos manuales al producto `minnie-y-mickey-mouse`): las
|
||||||
|
fotos sí se reflejaron al toque, confirmando el fix #1 — pero
|
||||||
|
apareció un segundo síntoma real, no relacionado: "el zoom
|
||||||
|
interactivo no se visualiza" en las fotos subidas a mano, aunque sí
|
||||||
|
en las de CSV. **Causa raíz #2** (`components/product/product-gallery.tsx`):
|
||||||
|
el panel/overlay de zoom arma `backgroundImage: \`url(${selected})\``
|
||||||
|
sin comillas — un nombre de archivo con espacio y paréntesis (ej.
|
||||||
|
`descarga (4)-xxx.png`, el patrón por defecto de Chrome al descargar
|
||||||
|
una imagen sin renombrarla, exactamente como quedan las subidas
|
||||||
|
manuales, a diferencia de las de CSV que llegan con nombres
|
||||||
|
saneados) rompe la sintaxis de `url()` sin comillas — el navegador
|
||||||
|
descarta el valor completo en silencio (`backgroundImage: none`).
|
||||||
|
La lupa (recuadro) sí aparecía porque no depende de la URL; solo el
|
||||||
|
panel/overlay quedaba en blanco. **Fix #2:** comillas alrededor de
|
||||||
|
la URL en las dos ocurrencias del patrón. Verificado en vivo contra
|
||||||
|
las 9 fotos reales del producto (2 de CSV + 7 manuales con
|
||||||
|
espacio+paréntesis) con Playwright: `computed backgroundImage`
|
||||||
|
pasó de `none` a `url("...")` correcto en las 7 que fallaban, sin
|
||||||
|
tocar las 2 que ya andaban bien.
|
||||||
|
23. **En mobile (viewport real de 320px), la página se cortaba y
|
||||||
|
obligaba a scrollear horizontalmente — RESUELTO 2026-09-20.**
|
||||||
|
Reportado por el usuario con captura real de `shop.cruzcloud.net`.
|
||||||
|
**No era la galería de imágenes** (esa clase de bug con `min-w-0`
|
||||||
|
ya estaba corregida desde la sesión anterior, confirmado en vivo
|
||||||
|
que seguía sana). Dos causas reales encontradas por separado,
|
||||||
|
reproducidas con Playwright contra un viewport de 320×480 real:
|
||||||
|
- **Footer, bloque "Newsletter"** (`components/layout/footer.tsx`):
|
||||||
|
el párrafo de texto, sin `min-w-0`, le pedía al grid item su
|
||||||
|
ancho de una sola línea sin wrappear (~335px, medido en vivo)
|
||||||
|
en vez de dejarse achicar — forzaba la única columna del grid
|
||||||
|
(`sm:grid-cols-2 lg:grid-cols-4`, sin `grid-cols-1` propio pero
|
||||||
|
con auto-stacking correcto por debajo de `sm`) a 335px de ancho,
|
||||||
|
arrastrando la página entera. Mismo bug de fondo que el ya
|
||||||
|
documentado en la galería, en un grid en vez de un
|
||||||
|
`overflow-x-auto`.
|
||||||
|
- **Navbar, fila de "Departamentos"/"Todo"** (`components/layout/navbar.tsx`):
|
||||||
|
los 3 items de esa fila (botón de menú, "Departamentos", "Todo")
|
||||||
|
tenían los tres `shrink-0` — ninguno podía achicarse, así que si
|
||||||
|
los tres no entraban en 320px, desbordaban sí o sí (`min-w-0` no
|
||||||
|
aplica cuando el ítem no está pensado para achicarse). Fix:
|
||||||
|
ocultar el texto "Departamentos" en mobile (queda ícono + flecha,
|
||||||
|
con `aria-label` agregado para no perder accesibilidad), libera
|
||||||
|
espacio suficiente para que quepan los tres.
|
||||||
|
Verificado en vivo con Playwright (viewport 320×480 real, no
|
||||||
|
emulación de dispositivo con DPR) contra staging y producción
|
||||||
|
después del deploy: `document.documentElement.scrollWidth ===
|
||||||
|
clientWidth` en home y en página de producto, en ambos ambientes —
|
||||||
|
sin overflow.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ADR 0011 — Ambiente de Staging para el storefront
|
||||||
|
|
||||||
|
**Estado:** aceptada — desplegado y verificado en vivo
|
||||||
|
**Fecha:** 2026-09-14
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Hasta este punto, todo cambio de frontend se probaba directamente
|
||||||
|
contra `shop.cruzcloud.net` (producción): el mismo push a `main` que
|
||||||
|
disparaba el build de imagen también actualizaba el manifiesto real
|
||||||
|
que sirve tráfico. Cada iteración de UI (rediseño de la página de
|
||||||
|
producto, zoom estilo Amazon, ajustes mobile) implicaba validar en
|
||||||
|
vivo sobre el sitio real, sin una red de contención — un cambio roto
|
||||||
|
llegaba directo al usuario final antes de poder revertirlo.
|
||||||
|
|
||||||
|
El usuario pidió explícitamente un ambiente de Staging para dejar de
|
||||||
|
probar en producción ("Me interesa crear el ambiente Staging para no
|
||||||
|
estar probando en Prod").
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| **Namespace + Ingress propios, backend compartido con Prod** — elegida | Cero costo adicional de Postgres/Medusa/Meilisearch en un NAS ya ajustado de recursos; rápido de armar reusando el mismo patrón workload+governance de `ecommerce` | Staging y Prod comparten datos reales (catálogo, inventario, órdenes) — no aísla pruebas que muten datos de negocio |
|
||||||
|
| Backend propio para Staging (Postgres/Medusa/Meilisearch duplicados) | Aislamiento completo, se podrían probar migraciones de datos sin riesgo | Duplica el consumo de recursos de la NAS sin necesidad todavía (decisión explícita del usuario de descartar esto por ahora) |
|
||||||
|
| Feature flags / preview deploys por PR en vez de un ambiente fijo | Sin infraestructura nueva que mantener | Gitea Actions en este lab no tiene un mecanismo de preview-per-branch ya armado; más esfuerzo de tooling que un namespace nuevo |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
Namespace `ecommerce-staging` nuevo, con su propio Ingress
|
||||||
|
(`staging.cruzcloud.net`) y su propio Deployment de frontend (misma
|
||||||
|
imagen Docker que Prod), pero apuntando al **mismo backend Medusa** que
|
||||||
|
producción (`medusa-svc.ecommerce.svc.cluster.local:9000` — FQDN
|
||||||
|
cross-namespace, un nombre corto solo resuelve dentro del mismo
|
||||||
|
namespace). Flujo de promoción: **todo push a `main` despliega a
|
||||||
|
Staging automáticamente**; promover a Producción es una acción manual
|
||||||
|
separada (`scripts/promote-to-production.sh`).
|
||||||
|
|
||||||
|
## Qué se armó
|
||||||
|
|
||||||
|
- `apps-registry/workloads/ecommerce-staging/` — overlay Kustomize:
|
||||||
|
1 réplica (no 2 como Prod — ambiente de verificación, no de tráfico
|
||||||
|
real), requests/limits más chicos, mismo `securityContext` que Prod
|
||||||
|
(mismas restricciones del Dockerfile: `runAsUser` numérico,
|
||||||
|
`NET_BIND_SERVICE`, `readOnlyRootFilesystem`).
|
||||||
|
- `apps-registry/apps/ecommerce-staging-app.yaml` +
|
||||||
|
`ecommerce-staging-governance-app.yaml` — Applications de Argo CD
|
||||||
|
(workload + governance separadas, misma convención que el resto del
|
||||||
|
lab).
|
||||||
|
- `platform-infra/ecommerce-staging-governance/` — ResourceQuota,
|
||||||
|
LimitRange y NetworkPolicy propios del namespace nuevo.
|
||||||
|
- `.gitea/workflows/build.yaml` — el paso de CD (`Actualizar
|
||||||
|
Manifiesto GitOps`) ahora apunta a
|
||||||
|
`workloads/ecommerce-staging/frontend/patches/deployment.yaml` en
|
||||||
|
vez del manifiesto de Prod: cada push a `main` bumpea el tag de
|
||||||
|
imagen (y, desde el mismo día, la variable `APP_VERSION` — ver nota
|
||||||
|
de implementación) de Staging automáticamente.
|
||||||
|
- `scripts/promote-to-production.sh` — lee el tag actualmente
|
||||||
|
desplegado en Staging, pide confirmación explícita, y lo aplica al
|
||||||
|
manifiesto real de Producción (`workloads/ecommerce/frontend/patches/deployment.yaml`).
|
||||||
|
Nunca corre automáticamente: es la única vía para que un cambio
|
||||||
|
llegue a `shop.cruzcloud.net`.
|
||||||
|
- Secrets `commerce-storefront` y `gitea-registry-secret` copiados
|
||||||
|
(no regenerados) desde el namespace `ecommerce` hacia
|
||||||
|
`ecommerce-staging`, para que el frontend de Staging tenga las
|
||||||
|
mismas credenciales reales contra el backend compartido.
|
||||||
|
|
||||||
|
## Descubrimiento de red real (no solo Kubernetes)
|
||||||
|
|
||||||
|
Confirmado en vivo el mismo día: aunque Cloudflare tiene un wildcard
|
||||||
|
`*.cruzcloud.net`, eso **no alcanza** para exponer un Ingress nuevo. La
|
||||||
|
cadena real es Cloudflare → túnel `cloudflared` → **Nginx Proxy
|
||||||
|
Manager** → `k3d-lab-cluster-serverlb` (puerto 90) → Traefik → Service.
|
||||||
|
Cada subdominio que ya funciona (`shop`, `commerce`, `media`, etc.)
|
||||||
|
tiene su propia fila de Proxy Host en NPM apuntando a
|
||||||
|
`192.168.68.61:90`. Un Ingress nuevo sin esa fila devuelve la página
|
||||||
|
por defecto de NPM aunque Traefik ya esté enrutando bien internamente
|
||||||
|
— verificado con `curl -H "Host: staging.cruzcloud.net"
|
||||||
|
http://192.168.68.61:90/` (funcionaba) contra el dominio público
|
||||||
|
(seguía devolviendo la página por defecto hasta que se agregó la fila
|
||||||
|
en NPM). Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
|
||||||
|
Esta fila de NPM la agregó el usuario manualmente — no hay credenciales
|
||||||
|
de ese panel disponibles para automatizarlo.
|
||||||
|
|
||||||
|
## Nota de implementación (2026-09-14): versión real visible en el footer
|
||||||
|
|
||||||
|
El footer mostraba un string fijo (`v4.1`), inútil para distinguir a
|
||||||
|
simple vista si Staging y Producción están en la misma versión.
|
||||||
|
Se agregó una variable de entorno `APP_VERSION` (leída en
|
||||||
|
`footer.tsx` vía `process.env.APP_VERSION`, sin necesidad de prefijo
|
||||||
|
`NEXT_PUBLIC_` por ser un Server Component) a ambos manifiestos de
|
||||||
|
Deployment; el mismo paso del CD que bumpea el tag de imagen ahora
|
||||||
|
también bumpea `APP_VERSION` (vía `perl -0777` para una sustitución
|
||||||
|
multilínea, ya que el nombre y el valor de la variable viven en líneas
|
||||||
|
distintas del YAML — un `sed` de una sola línea no alcanza).
|
||||||
|
`promote-to-production.sh` hace el mismo bump al promover. Verificado
|
||||||
|
en vivo con Playwright: tras el primer push, Staging mostró `v1.0.263`
|
||||||
|
mientras Producción seguía en `v4.1` (la imagen vieja no leía la
|
||||||
|
variable); tras promover, ambos ambientes quedaron visualmente
|
||||||
|
distinguibles por versión real en su footer.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Ganado: ningún cambio de frontend vuelve a validarse por primera vez
|
||||||
|
contra tráfico real; Staging y Producción ahora se distinguen a
|
||||||
|
simple vista por la versión mostrada en el footer.
|
||||||
|
- Deuda aceptada: Staging comparte datos reales de negocio con
|
||||||
|
Producción (mismo Medusa/Postgres) — una prueba que cree o modifique
|
||||||
|
órdenes/inventario en Staging afecta datos reales. Si esto se vuelve
|
||||||
|
un problema, la opción descartada de backend propio para Staging
|
||||||
|
queda como camino de escalamiento.
|
||||||
|
- Para revertir: apuntar de nuevo `MANIFEST_FILE`/`KUSTOMIZE_DIR` en
|
||||||
|
`build.yaml` hacia `workloads/ecommerce/...` y eliminar el namespace
|
||||||
|
`ecommerce-staging` — el namespace de Producción no cambia de forma
|
||||||
|
en ningún momento de este ADR.
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# ADR 0012 — Práctica multi-cloud: GCP y Azure DevOps en paralelo al lab ZimaOS
|
||||||
|
|
||||||
|
**Estado:** aceptada — Fase 1 (Google Workspace) completa y verificada
|
||||||
|
en vivo, con un giro real: el MCP self-hosted planeado
|
||||||
|
(`taylorwilsdon/google_workspace_mcp`) quedó abandonado por un límite
|
||||||
|
real de esa librería (ver §Fase 1), reemplazado por los Connectors
|
||||||
|
nativos de claude.ai. Fases 2-4 quedan documentadas como roadmap,
|
||||||
|
explícitamente no implementadas hasta que se pidan en una sesión
|
||||||
|
futura.
|
||||||
|
**Fecha:** 2026-09-20 (Fase 1 cerrada 2026-09-21)
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El lab GitOps de CruzCloud (ZimaOS + k3d + Gitea + Argo CD) ya prueba en
|
||||||
|
vivo, con rigor de ADR, patrones que después se pueden proponer en
|
||||||
|
trabajo real con clientes — pero corre íntegro sobre hardware propio,
|
||||||
|
sin tocar ningún proveedor de nube público. Dos motivaciones concretas
|
||||||
|
empujan a abrir una segunda línea de práctica en paralelo:
|
||||||
|
|
||||||
|
1. **Practicar herramientas que el cliente actual restringe.** Ciertas
|
||||||
|
plataformas/herramientas de nube no están disponibles en el entorno
|
||||||
|
de trabajo del cliente actual — la única forma real de ganar
|
||||||
|
experiencia con ellas es un entorno propio.
|
||||||
|
2. **Preparar propuestas de arquitectura con evidencia, no solo teoría.**
|
||||||
|
Poder decir "ya lo corrí, así se ve, esto cuesta X" en una propuesta
|
||||||
|
a un cliente vale más que una descripción de manual.
|
||||||
|
3. **Portafolio.** Los mismos ADRs, diagramas y decisiones documentadas
|
||||||
|
que ya sostiene este lab sirven como muestra de trabajo verificable.
|
||||||
|
|
||||||
|
**Alcance confirmado explícitamente por el usuario, no asumido:**
|
||||||
|
|
||||||
|
- **Entorno paralelo, no migración.** ZimaOS sigue siendo el lab
|
||||||
|
principal — nada de lo de acá reemplaza nada de lo ya construido.
|
||||||
|
- **Cuentas personales únicamente.** Nunca credenciales del cliente
|
||||||
|
actual ni de ningún cliente futuro — separación total.
|
||||||
|
- **Costo cero o mínimo**, con alertas de presupuesto como red de
|
||||||
|
seguridad desde el primer recurso pago que se cree (ver
|
||||||
|
[Análisis de costo](#analisis-de-costo-explicito) más abajo).
|
||||||
|
- Mismo rigor documental que el resto del lab: un ADR por decisión
|
||||||
|
arquitectónica real, diagramas Mermaid, cifras verificadas en vivo o
|
||||||
|
contra fuente primaria (no contra un blog de terceros sin
|
||||||
|
corroborar — ver el caso del supuesto MCP "oficial" de Google
|
||||||
|
Workspace en la [Fase 1](#fase-1-ejecutada-google-workspace-con-un-giro-real-en-la-implementacion)).
|
||||||
|
|
||||||
|
## Diagrama del lab actual (ZimaOS)
|
||||||
|
|
||||||
|
Versión simplificada, con solo los componentes relevantes para la
|
||||||
|
comparación de este ADR — el diagrama completo (con Traefik,
|
||||||
|
`serverlb`, cada `Host` de Ingress, etc.) vive en
|
||||||
|
[Visión general](../arquitectura/vision-general.md).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User(["Usuario"]) -->|HTTPS| CF["Cloudflare Tunnel<br/>(sin puertos abiertos)"]
|
||||||
|
CF --> NPM["Nginx Proxy Manager<br/>enrutamiento por dominio"]
|
||||||
|
NPM --> K3D["k3d<br/>(cluster K8s local, Docker)"]
|
||||||
|
|
||||||
|
subgraph K3D_INT["Dentro de k3d"]
|
||||||
|
ARGO["Argo CD"] -->|sync| APPS["Apps reales<br/>(ecommerce, docs-portal)"]
|
||||||
|
GIT["Gitea<br/>(auto-alojado)"] -.->|watch repos| ARGO
|
||||||
|
end
|
||||||
|
K3D --> K3D_INT
|
||||||
|
```
|
||||||
|
|
||||||
|
**Costo real:** $0 — hardware propio (NAS ZimaOS), sin proveedor de
|
||||||
|
nube de por medio.
|
||||||
|
|
||||||
|
## Diagrama Fase 1 — entorno paralelo en GCP (VM + k3s)
|
||||||
|
|
||||||
|
**Esto NO es GKE administrado.** Es una VM `e2-micro` corriendo k3s
|
||||||
|
self-managed, elegida específicamente para mantener el costo real en
|
||||||
|
$0 — GKE (administrado, ver [Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado))
|
||||||
|
no tiene una vía 100% gratuita permanente (ver el análisis de costo
|
||||||
|
más abajo).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User(["Usuario / Claude"]) -->|SSH / kubectl| VM["Compute Engine e2-micro<br/>Always Free — us-west1/us-central1/us-east1<br/>NO es GKE administrado"]
|
||||||
|
|
||||||
|
subgraph VM_INT["Dentro de la VM"]
|
||||||
|
K3S["k3s<br/>(single-node, self-managed)"]
|
||||||
|
ARGO2["Argo CD<br/>(instancia propia, no la de ZimaOS)"] -->|sync| APPS2["Manifiestos del nuevo repo<br/>(mismo patrón base+overlay<br/>de platform-templates)"]
|
||||||
|
end
|
||||||
|
VM --> VM_INT
|
||||||
|
|
||||||
|
GIT_EXISTENTE["Gitea existente<br/>gitea.cruzcloud.net<br/>(reusado, repo nuevo, NO uno nuevo)"] -.->|watch| ARGO2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Por qué reusar el Gitea existente en vez de levantar uno nuevo en
|
||||||
|
GCP:** el objetivo es practicar el patrón GitOps completo (Kustomize
|
||||||
|
base+overlay + Argo CD sincronizando) con el mínimo de piezas nuevas —
|
||||||
|
levantar un segundo Gitea solo sumaría superficie a mantener sin
|
||||||
|
enseñar nada nuevo sobre GCP en sí. El *cómputo* (la VM, k3s) es lo que
|
||||||
|
cambia de proveedor; el origen de verdad (Git) se queda donde ya
|
||||||
|
funciona.
|
||||||
|
|
||||||
|
**Costo real:** $0 — `e2-micro` es Always Free **de forma permanente**
|
||||||
|
(no es el trial de $300/90 días), siempre que la VM viva en
|
||||||
|
`us-west1`, `us-central1` o `us-east1` y no supere 1 instancia
|
||||||
|
no-preemptible + 30 GB-mes de disco persistente estándar + 1 GB/mes de
|
||||||
|
salida hacia Norteamérica — límites reales del programa, verificados
|
||||||
|
contra la documentación oficial de Google Cloud.[^1]
|
||||||
|
|
||||||
|
## Fase futura (no implementada) — GKE real con Spot + auto-apagado
|
||||||
|
|
||||||
|
Marcada explícitamente como **fase futura, no implementada** — ver
|
||||||
|
[Fase 4](#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)
|
||||||
|
para cuándo se decidiría ejecutarla.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
TF["Terraform<br/>(provisiona todo)"] --> GKE["GKE Standard<br/>1 cluster zonal"]
|
||||||
|
GKE --> NP["Node pool<br/>Spot VMs (e2-small)"]
|
||||||
|
|
||||||
|
SCHED["Cloud Scheduler<br/>(cron: apagar/prender)"] -->|invoca| FN["Cloud Run/Functions<br/>job de auto-apagado"]
|
||||||
|
FN -->|resize node pool<br/>a 0 / de vuelta| NP
|
||||||
|
|
||||||
|
style GKE fill:#333,stroke:#f90,color:#fff
|
||||||
|
style NP fill:#333,stroke:#f90,color:#fff
|
||||||
|
style SCHED fill:#333,stroke:#f90,color:#fff
|
||||||
|
style FN fill:#333,stroke:#f90,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mecanismo de auto-apagado: comparación antes de recomendar
|
||||||
|
|
||||||
|
Dos mecanismos reales existen para esto; se investigaron ambos antes de
|
||||||
|
elegir uno, sin asumir cuál es "obviamente" mejor:
|
||||||
|
|
||||||
|
| | Escalar node pool a 0 | Destruir/recrear cluster completo (`terraform apply`/`destroy` programado) |
|
||||||
|
|---|---|---|
|
||||||
|
| Qué apaga | Solo el cómputo (los nodos) | Todo, incluido el control plane |
|
||||||
|
| Ahorra el fee de control plane ($0.10/hora) | No — pero **ya está cubierto por el crédito mensual de $74.40** para un único cluster, así que no hay nada real que ahorrar ahí | Sí, pero es un ahorro sobre algo que ya es $0 |
|
||||||
|
| Tiempo de "volver a prender" | ~1-3 min (crear nodos sobre un cluster que ya existe) | ~5-10 min (crear el cluster entero de cero) |
|
||||||
|
| Piezas para automatizarlo | Cloud Scheduler → Cloud Function/Run → 1 llamada a la API de GKE (resize node pool) | Cloud Scheduler → runner con Terraform + credenciales + state remoto seguro → `apply`/`destroy` completo |
|
||||||
|
| Riesgo de drift/config perdida | Bajo — el cluster nunca se borra | Mayor — cualquier cosa hecha fuera de Terraform se pierde en cada destroy |
|
||||||
|
| Patrón documentado en la industria | Sí, es el patrón estándar (Cloud Scheduler + Pub/Sub + Cloud Function), con casos reales de ~30% de ahorro de cómputo en clusters no-críticos[^2] | Existe, pero es la opción menos común para este caso puntual |
|
||||||
|
|
||||||
|
**Recomendación (sin implementar todavía):** escalar el node pool a
|
||||||
|
0/de vuelta vía Cloud Scheduler + Cloud Function, no destruir/recrear
|
||||||
|
el cluster completo. Razón concreta: el crédito de $74.40/mes **ya
|
||||||
|
cubre el fee de control plane de un único cluster exista o no exista
|
||||||
|
un nodo adentro** — destruir el cluster entero no ahorra nada sobre
|
||||||
|
ese componente porque ya es $0, y a cambio suma la complejidad real de
|
||||||
|
manejar Terraform state de forma segura dentro de un cron job
|
||||||
|
automatizado, más un tiempo de arranque 3-5x más largo. La única razón
|
||||||
|
real para preferir destruir/recrear sería eliminar *toda* huella en
|
||||||
|
GCP entre usos (por ejemplo, para no depender de que el crédito
|
||||||
|
mensual siga existiendo) — no es el caso acá.
|
||||||
|
|
||||||
|
## Tabla comparativa
|
||||||
|
|
||||||
|
| | Lab actual (ZimaOS) | GCP Fase 1 (VM + k3s) | GCP Fase futura (GKE + Spot) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cómputo | NAS propio, Docker/k3d | Compute Engine `e2-micro` | GKE Standard, node pool Spot `e2-small` |
|
||||||
|
| ¿Kubernetes administrado? | No (k3d local) | No (k3s self-managed) | Sí (GKE Standard) |
|
||||||
|
| Control plane | N/A (local) | N/A (k3s corre en la misma VM) | $0.10/hora — cubierto por crédito de $74.40/mes para 1 cluster |
|
||||||
|
| Cómputo de nodos | $0 (hardware propio) | $0 (Always Free permanente) | ~$7-10/mes (Spot `e2-small`, sin cubrir por ningún crédito) |
|
||||||
|
| GitOps | Argo CD + Gitea, ambos autoalojados | Argo CD propio en la VM, mismo Gitea reusado | Igual que Fase 1, sobre GKE |
|
||||||
|
| Costo mensual real | $0 | $0 | ~$7-10/mes (no $0) |
|
||||||
|
| Estado | En producción, uso diario | A ejecutar esta sesión (Parte 2/3) | Documentado, no implementado |
|
||||||
|
|
||||||
|
## Análisis de costo explícito
|
||||||
|
|
||||||
|
**No existe un GKE administrado 100% gratis de forma permanente.** El
|
||||||
|
crédito de $74.40/mes por cuenta de facturación cubre exactamente el
|
||||||
|
fee de control plane ($0.10/hora) de **un** cluster zonal Standard o
|
||||||
|
Autopilot — nunca el cómputo real. GKE Autopilot factura desde el
|
||||||
|
primer pod que se programa (vCPU/memoria/almacenamiento por pod), sin
|
||||||
|
ningún equivalente al Always Free de Compute Engine; GKE Standard sí
|
||||||
|
puede correr sobre un único nodo Spot/preemptible barato (~$7/mes para
|
||||||
|
un `e2-small`, verificado contra tarifas actuales), pero ese cómputo
|
||||||
|
nunca es gratis.[^3]
|
||||||
|
|
||||||
|
**La única ruta de costo cero real y permanente es Compute Engine
|
||||||
|
`e2-micro` (Always Free) + k3s self-managed** — exactamente la Fase 1
|
||||||
|
de este ADR. No es el trial de $300/90 días (que expira y no es
|
||||||
|
"gratis", es prepago con reloj corriendo); es un beneficio permanente
|
||||||
|
del programa de nivel gratuito de Google Cloud, activo mientras la VM
|
||||||
|
cumpla los límites de región/tamaño/tráfico ya descritos arriba.
|
||||||
|
|
||||||
|
**Activar cualquier billing account de GCP requiere tarjeta igual**,
|
||||||
|
incluso para quedarse exclusivamente en recursos Always Free — Google
|
||||||
|
verifica el medio de pago aunque el cargo real sea $0. Detalle no
|
||||||
|
obvio, verificado contra documentación oficial: **las alertas de
|
||||||
|
presupuesto no se pueden configurar mientras la cuenta esté operando
|
||||||
|
sobre crédito gratuito de prueba** — hace falta pasar la cuenta de
|
||||||
|
facturación a estado "paga" (el uso real sigue siendo $0 si no se sale
|
||||||
|
del Always Free) para que la opción de crear un presupuesto/alerta
|
||||||
|
quede disponible.[^4] **Recomendación:** hacer ese paso — pasar a
|
||||||
|
cuenta de facturación paga — como parte del setup inicial, y configurar
|
||||||
|
de inmediato una alerta de presupuesto en **$1-5 USD** como red de
|
||||||
|
seguridad, antes de crear ningún recurso más allá de lo cubierto por
|
||||||
|
Always Free.
|
||||||
|
|
||||||
|
## Secuencia de fases
|
||||||
|
|
||||||
|
Todas documentadas acá; **solo la Fase 1 se ejecuta en esta sesión.**
|
||||||
|
Las Fases 2-4 no se implementan sin pedido explícito en una sesión
|
||||||
|
futura — quedan como roadmap, no como trabajo en curso.
|
||||||
|
|
||||||
|
### Fase 1 (ejecutada) — Google Workspace, con un giro real en la implementación
|
||||||
|
|
||||||
|
**Objetivo cumplido — acceso real a Google Calendar desde Claude,
|
||||||
|
verificado en vivo — pero por un camino distinto al planeado
|
||||||
|
originalmente.** Detalle de la investigación previa (qué MCP usar,
|
||||||
|
oficial vs comunitario) — resumen: **no existe un MCP oficial de
|
||||||
|
Google Workspace** publicado por Google (se verificó explícitamente
|
||||||
|
buscando en los GitHub `google`/`googleworkspace` oficiales — no
|
||||||
|
aparece ninguno; un solo blog de terceros afirma que "ya existe uno
|
||||||
|
oficial" sin que ninguna otra fuente lo corrobore, así que se descarta
|
||||||
|
esa afirmación por no verificable). Se optó por
|
||||||
|
`taylorwilsdon/google_workspace_mcp` (comunitario, el más completo y
|
||||||
|
activamente mantenido: 120+ herramientas, OAuth 2.1, +2500 estrellas)
|
||||||
|
como primer intento, self-hosted dentro de `tmux-host`.
|
||||||
|
|
||||||
|
**Ese self-hosted quedó abandonado — límite real encontrado en la
|
||||||
|
librería, no un error de configuración.** El servidor usa FastMCP's
|
||||||
|
`GoogleProvider`/`OAuthProxy`, que construye el `redirect_uri`
|
||||||
|
enviado a Google directamente desde su propio `base_url` — **no**
|
||||||
|
desde la variable `GOOGLE_OAUTH_REDIRECT_URI` que el proyecto expone
|
||||||
|
(esa variable solo valida entradas, no arma la salida real; hallazgo
|
||||||
|
confirmado leyendo el código fuente instalado, no la documentación
|
||||||
|
pública). Y `base_url` es la misma variable de la que depende por
|
||||||
|
default `issuer_url`, que el SDK de MCP exige sea HTTPS salvo que sea
|
||||||
|
literalmente `localhost`/`127.0.0.1` (chequeo hardcodeado en
|
||||||
|
`mcp/server/auth/routes.py:validate_issuer_url`, sin variable de
|
||||||
|
entorno que lo salte). Resultado: **no hay combinación de variables
|
||||||
|
de entorno que satisfaga a la vez** "el navegador del usuario debe
|
||||||
|
poder llegar al callback" (necesita un dominio real, no `localhost`)
|
||||||
|
y "el issuer debe ser HTTPS o `localhost`" — `workspace-mcp` no
|
||||||
|
expone `issuer_url` por separado de `base_url` en su capa de
|
||||||
|
configuración (confirmado en `core/server.py`: solo pasa `base_url` a
|
||||||
|
`GoogleProvider(...)`).
|
||||||
|
|
||||||
|
**Decisión del usuario ante ese límite:** en vez de armar un proxy
|
||||||
|
HTTPS con certificado autofirmado (infraestructura extra, fricción de
|
||||||
|
certificado no confiable en cada uso) o parchear el paquete instalado
|
||||||
|
a mano (frágil, se pierde en cualquier reinstalación), se usaron los
|
||||||
|
**Connectors nativos de claude.ai** (Google Calendar, vía el
|
||||||
|
directorio de conectores de la cuenta, fuera de Claude Code por
|
||||||
|
completo) — mismo resultado práctico (Claude con acceso real al
|
||||||
|
calendario personal), infraestructura cero de nuestro lado, sin
|
||||||
|
tocar código de terceros. Contrapartida real: es una integración de
|
||||||
|
cuenta de claude.ai, no un MCP configurado dentro del proyecto
|
||||||
|
`platform-multicloud` — no aparece como herramienta en sesiones de
|
||||||
|
Claude Code (`lab`/`cloud-practice`), solo en el chat normal de
|
||||||
|
claude.ai.
|
||||||
|
|
||||||
|
**Verificado en vivo:** pregunta real en claude.ai normal ("¿qué
|
||||||
|
tengo en el calendario esta semana?") devolvió una respuesta correcta
|
||||||
|
revisando 3 calendarios reales de la cuenta (principal, Familia,
|
||||||
|
festivos de Colombia) vía la herramienta `Google Calendar: Lists
|
||||||
|
calendar events`, con razonamiento correcto sobre qué cuenta como
|
||||||
|
"esta semana" dado el día/hora real de la consulta.
|
||||||
|
|
||||||
|
### Fase 2 (documentar, no ejecutar) — Terraform aislado
|
||||||
|
|
||||||
|
Instalar el marketplace oficial de HashiCorp Agent Skills, y practicar
|
||||||
|
aprovisionando algo mínimo y desechable en GCP. Nota para cuando se
|
||||||
|
ejecute: HashiCorp también publica un **MCP Server de Terraform**
|
||||||
|
propio y oficial (`hashicorp/terraform-mcp-server`, integra con el
|
||||||
|
Terraform Registry y HCP Terraform) — es una pieza distinta a los
|
||||||
|
Agent Skills (el MCP es el "tubo" hacia herramientas reales; los
|
||||||
|
Skills son paquetes de conocimiento/instrucciones) y vale la pena
|
||||||
|
evaluar si se usan juntos o solo uno de los dos en ese momento.
|
||||||
|
|
||||||
|
### Fase 3 (documentar, no ejecutar) — Azure DevOps en paralelo
|
||||||
|
|
||||||
|
MCP oficial `microsoft/azure-devops-mcp` (confirmado como el oficial y
|
||||||
|
activamente mantenido por Microsoft, hay varios forks/alternativas
|
||||||
|
comunitarias que no son la vía recomendada). Replicar el mismo
|
||||||
|
pipeline DevSecOps que ya corre en Gitea Actions para este lab
|
||||||
|
(Gitleaks → Trivy → Semgrep → Syft → Cosign) como Azure Pipelines YAML,
|
||||||
|
en un proyecto personal — mismo objetivo de práctica que la Fase 1:
|
||||||
|
tener el patrón ya probado para proponerlo en trabajo con clientes que
|
||||||
|
usen Azure DevOps.
|
||||||
|
|
||||||
|
### Fase 4 (documentar, no ejecutar) — GKE real con Spot + auto-apagado
|
||||||
|
|
||||||
|
Solo si se decide conscientemente asumir el costo bajo pero no-cero
|
||||||
|
(~$7-10/mes, ver el análisis de costo arriba) — vía Terraform, node
|
||||||
|
pool Spot, y el mecanismo de auto-apagado recomendado en la sección de
|
||||||
|
arriba (escalar a 0, no destruir/recrear el cluster).
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| Entorno paralelo en GCP+Azure (elegido) | Practica exactamente lo que el cliente actual restringe; portafolio con evidencia real; ZimaOS sigue intacto | Dos entornos más que mantener; curva de aprendizaje de dos proveedores nuevos a la vez |
|
||||||
|
| Migrar el lab completo a un solo cloud | Un solo entorno que mantener | Pierde justo lo que se quiere conservar (costo $0, control total de hardware); no es lo que se pidió |
|
||||||
|
| Solo AWS (el "default" de la industria) | Ecosistema más grande | El cliente actual ya usa/restringe otras plataformas específicas — practicar AWS no cierra esa brecha puntual |
|
||||||
|
| GKE administrado desde el día 1 (saltear la VM self-managed) | Más parecido a "producción real" desde el inicio | Rompe el requisito de costo cero explícito del usuario — ningún tier de GKE es gratis de forma permanente |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
Se abre una línea de práctica multi-cloud paralela al lab ZimaOS, en
|
||||||
|
cuentas personales, arrancando por Compute Engine `e2-micro` (Always
|
||||||
|
Free) + k3s self-managed en GCP como Fase 1 de cómputo, y Google
|
||||||
|
Workspace vía MCP comunitario (`taylorwilsdon/google_workspace_mcp`)
|
||||||
|
como primer MCP a configurar en esta sesión. Terraform, Azure DevOps y
|
||||||
|
GKE administrado quedan documentados como Fases 2-4, sin implementar.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- **Se gana** un segundo entorno real, documentado con el mismo rigor
|
||||||
|
de ADR que ya sostiene el lab, listo para mostrar en propuestas a
|
||||||
|
clientes — sin tocar nunca credenciales de cliente.
|
||||||
|
- **Se sacrifica/queda como deuda técnica:** la Fase 1 de GCP usa k3s
|
||||||
|
self-managed, no un Kubernetes administrado real — cualquier lección
|
||||||
|
específica de operar GKE (autoscaling nativo, node pools, IAM de
|
||||||
|
GCP) queda pendiente hasta la Fase 4, y esa fase sí tiene costo real
|
||||||
|
(~$7-10/mes) que hoy no se está pagando.
|
||||||
|
- **Para revertir:** dar de baja la VM `e2-micro` y (si se llegó a
|
||||||
|
crear) el node pool de la Fase 4 — ningún componente de este ADR
|
||||||
|
toca o depende del lab ZimaOS existente, así que no hay nada que
|
||||||
|
revertir de ese lado.
|
||||||
|
|
||||||
|
[^1]: [Compute Engine Free Tier — Google Cloud](https://cloud.google.com/free/docs/compute-getting-started), [GCP Free Tier Complete Guide 2026](https://agentdeals.dev/gcp-free-tier-2026)
|
||||||
|
[^2]: [Reduce Your GKE Bill with Cloud Scheduler by Scaling to Zero](https://chimbu.medium.com/reduce-your-gke-bill-with-cloud-scheduler-by-scaling-to-zero-during-off-peak-hours-9796bb4f7b8b), [Scaling GKE Standard Clusters to Zero](https://medium.com/@gustavo.romerobenitez_34616/scaling-gke-standard-clusters-to-zero-56bf5270510e)
|
||||||
|
[^3]: [GKE Pricing Guide 2026 — Sedai](https://sedai.io/blog/google-cloud-kubernetes-cluster-cost), [e2-small pricing — Economize](https://www.economize.cloud/resources/gcp/pricing/compute-engine/e2-small/), [Google Kubernetes Engine pricing — Google Cloud](https://cloud.google.com/kubernetes-engine/pricing)
|
||||||
|
[^4]: [Google Cloud Free Trial FAQs](https://cloud.google.com/signup-faqs), [Create, edit, or delete budgets and budget alerts — Google Cloud](https://docs.cloud.google.com/billing/docs/how-to/budgets)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR 0013 — Iniciativas de clientes: Google Apps Script y AppSheet
|
||||||
|
|
||||||
|
**Estado:** aceptada — **fase prioritaria**, precede la reanudación de
|
||||||
|
las Fases 2-4 del [ADR 0012](0012-practica-multicloud-gcp-azure.md).
|
||||||
|
Scaffold del repo en curso; primeras iniciativas reales se agregan
|
||||||
|
sesión a sesión, a medida que se comparten.
|
||||||
|
**Fecha:** 2026-09-21
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Iniciativas propias de la empresa (no del lab), trabajadas con **cuenta
|
||||||
|
personal de Google** — dos herramientas concretas: **Google Apps
|
||||||
|
Script** (código, automatizaciones) y **Google AppSheet** (apps
|
||||||
|
no-code sobre Sheets). Se abre como fase separada del ADR 0012 y con
|
||||||
|
prioridad sobre él por decisión explícita: retomar las Fases 2-4 del
|
||||||
|
lab (Terraform, Azure DevOps, GKE) espera a que esto avance primero.
|
||||||
|
|
||||||
|
**Restricción dura, no negociable:** ningún repo ni documento de esta
|
||||||
|
iniciativa contiene credenciales, datos ni información real de
|
||||||
|
cliente. Apps Script versiona código (lógica), nunca datos. AppSheet
|
||||||
|
no tiene export de código — solo se documenta desde afuera (tablas,
|
||||||
|
columnas, expresiones, capturas), nunca se copian datos reales de
|
||||||
|
ninguna hoja de cálculo al repo.
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
### Apps Script: MCP vs. `clasp`
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| MCP para Apps Script | Interacción conversacional directa | **No existe un MCP oficial de Google.** Se verificó explícitamente buscando — solo hay servidores comunitarios (`mohalmah/google-appscript-mcp-server`, `whichguy/gas_mcp`, entre otros), ninguno publicado por Google. Se descartan sin evaluar más a fondo: no hay necesidad de agregar una dependencia comunitaria no verificada cuando existe una CLI oficial que cubre el caso de uso completo. |
|
||||||
|
| **`clasp`** (elegido) | CLI oficial de Google (`@google/clasp`), mantenida por el equipo de Apps Script, cubre push/pull/login/versiones/despliegues | Requiere login interactivo (OAuth) — mitigado: el login lo corre el dueño de la cuenta, nunca se comparte el código de autorización |
|
||||||
|
|
||||||
|
### AppSheet: qué se puede versionar
|
||||||
|
|
||||||
|
Se verificó contra la documentación oficial vigente (no se asumió):
|
||||||
|
**AppSheet no expone ningún mecanismo de export de la definición de la
|
||||||
|
app** (tablas virtuales, vistas, expresiones, automatizaciones) hacia
|
||||||
|
un formato versionable con git. La única superficie exportable es la
|
||||||
|
app en sí vía la consola de AppSheet, no un artefacto de código. Existe
|
||||||
|
un addon de terceros ("App Script Saver") pero es específico de Apps
|
||||||
|
Script, no de AppSheet, y no se evaluó como parte de esta decisión
|
||||||
|
(mismo criterio que con los MCP: no se instala nada de terceros sin
|
||||||
|
pasar primero por la verificación de publicador/permisos ya establecida
|
||||||
|
en el lab).
|
||||||
|
|
||||||
|
**Decisión sobre AppSheet:** no se intenta versionar la app —se
|
||||||
|
documenta desde afuera. La documentación (markdown + capturas) es la
|
||||||
|
única fuente versionada; la app real en la consola de AppSheet sigue
|
||||||
|
siendo la fuente de verdad operativa, con el riesgo explícito de que
|
||||||
|
la documentación quede desactualizada (ver Consecuencias).
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
Repo privado nuevo `client-initiatives` en Gitea (separado de
|
||||||
|
`apps-registry`/`platform-infra`/`scripts`/`platform-multicloud` — ni
|
||||||
|
lab GitOps ni práctica multi-cloud, es su propia categoría). Estructura:
|
||||||
|
una carpeta por iniciativa bajo `apps-script/` (proyecto `clasp`
|
||||||
|
completo, con su propio `.clasp.json`) o `appsheet/` (documentación +
|
||||||
|
plantilla de estructura vacía). Mismo criterio de versionado semántico
|
||||||
|
y modularidad que ya usa `docs-portal`.
|
||||||
|
|
||||||
|
**Flujo Apps Script:** rama `fix/*` → diff mostrado → `merge --no-ff`
|
||||||
|
a `main`. `clasp push` (subir el código a Google) es una confirmación
|
||||||
|
explícita **aparte**, nunca agrupada con el merge a git — son dos
|
||||||
|
sistemas de versionado distintos (git para el repo, Apps Script para
|
||||||
|
las versiones desplegadas) y no deben confundirse.
|
||||||
|
|
||||||
|
**Gitleaks:** se agrega al pipeline del repo nuevo. Nota real
|
||||||
|
encontrada al planificar esto: Gitleaks hoy solo corre en
|
||||||
|
`apps-registry` (3 workflows) — `platform-infra` y `scripts` no tienen
|
||||||
|
Gitea Actions configurado en absoluto, no hay "los otros tres repos con
|
||||||
|
Gitleaks" como se asumió al principio. Se replica el step exacto que
|
||||||
|
ya usa `apps-registry` (Gitleaks v8.21.2, `--no-git --redact`, reporte
|
||||||
|
JSON) por ser el único precedente real en el lab.
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
### Fase A — Google Apps Script
|
||||||
|
|
||||||
|
1. Scaffold del repo `client-initiatives` (estructura, Gitleaks,
|
||||||
|
convenciones documentadas).
|
||||||
|
2. Login de `clasp` (`clasp login --no-localhost`) corrido por el
|
||||||
|
dueño de la cuenta — verificación posterior de que
|
||||||
|
`~/.clasprc.json` queda fuera de cualquier repo y con `chmod 600`.
|
||||||
|
3. Primeras iniciativas: pendientes de compartir, se agregan una por
|
||||||
|
una bajo `apps-script/<slug>/` a medida que llegan.
|
||||||
|
|
||||||
|
### Fase B — Google AppSheet
|
||||||
|
|
||||||
|
1. Por cada app existente: documentar tablas, columnas y expresiones
|
||||||
|
en markdown, más capturas del estado actual, bajo
|
||||||
|
`appsheet/<slug>/`.
|
||||||
|
2. Plantilla de estructura de datos vacía si aplica (schema, nunca
|
||||||
|
filas reales).
|
||||||
|
3. Limitación dejada explícita en cada README de iniciativa: sin diff
|
||||||
|
automático posible entre esta doc y la app real.
|
||||||
|
|
||||||
|
## En pausa mientras esta fase esté activa
|
||||||
|
|
||||||
|
Por decisión explícita del usuario, prioridad sobre lo ya documentado
|
||||||
|
en el ADR 0012 y el resto del lab:
|
||||||
|
|
||||||
|
- **Swap de `tmux-host`** — no se corre en esta fase.
|
||||||
|
- **[Fase 4 del ADR 0012](0012-practica-multicloud-gcp-azure.md#fase-4-documentar-no-ejecutar-gke-real-con-spot-auto-apagado)**
|
||||||
|
(GKE real con Spot + auto-apagado) — sigue solo documentada.
|
||||||
|
- **Prueba del skill `frontend-design`** (pendiente desde la
|
||||||
|
instalación de plugins del 2026-09-20) — no se ejecuta.
|
||||||
|
|
||||||
|
Tampoco se toca NPM, `cloudflared`, ni `lab-agent` durante esta fase.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Se gana un espacio versionado y auditado (Gitleaks) para
|
||||||
|
automatizaciones reales de la empresa, sin mezclar nada con el lab
|
||||||
|
GitOps ni con la práctica multi-cloud.
|
||||||
|
- Se acepta como deuda consciente que la documentación de AppSheet
|
||||||
|
puede desincronizarse de la app real — no hay forma técnica de
|
||||||
|
evitarlo, solo disciplina de actualizarla.
|
||||||
|
- Revertir esta decisión implicaría: eliminar el repo
|
||||||
|
`client-initiatives` (las iniciativas seguirían existiendo en Google,
|
||||||
|
solo se perdería el historial versionado y la documentación externa).
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z |
|
||||||
|
| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) |
|
||||||
|
| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic |
|
||||||
|
| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa |
|
||||||
|
|
||||||
|
## Contexto / disparador
|
||||||
|
|
||||||
|
El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las
|
||||||
|
sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai.
|
||||||
|
Parecía un problema del contenedor, así que se eliminó `tmux-host` para
|
||||||
|
recrearlo desde cero.
|
||||||
|
|
||||||
|
En realidad ese síntoma tenía una causa que no tenía nada que ver con el
|
||||||
|
contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al
|
||||||
|
recrear el contenedor, la mitad de la noche se fue en resolver tres fallos
|
||||||
|
reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada
|
||||||
|
uno se encontró recién después de resolver el anterior. Recién al final
|
||||||
|
apareció la causa original.
|
||||||
|
|
||||||
|
!!! info "Fuentes de la evidencia"
|
||||||
|
Este playbook se escribió desde dentro del `tmux-host` ya recuperado.
|
||||||
|
Los datos marcados **(verificado)** se confirmaron contra el estado
|
||||||
|
real del sistema al escribirlo: `docker inspect`, `docker images`,
|
||||||
|
`health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los
|
||||||
|
marcados **(reportado)** vienen del relato de la sesión de
|
||||||
|
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
|
||||||
|
transcript ni historial de shell que se pueda revisar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Causa a — Contenedor recreado sin los volúmenes de autenticación
|
||||||
|
|
||||||
|
**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el
|
||||||
|
diálogo de confianza del folder. `--continue` no encontraba ninguna
|
||||||
|
conversación previa y arrancaba una en blanco.
|
||||||
|
|
||||||
|
**Diagnóstico.** El `docker run` que se usó para recrear el contenedor
|
||||||
|
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
|
||||||
|
[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)
|
||||||
|
**(reportado)**. Faltaban justamente los dos de autenticación y estado
|
||||||
|
de Claude Code:
|
||||||
|
|
||||||
|
| Volumen faltante | Qué se perdía |
|
||||||
|
|---|---|
|
||||||
|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria |
|
||||||
|
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control |
|
||||||
|
|
||||||
|
Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del
|
||||||
|
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
|
||||||
|
se reconstruyó varias veces con el mismo comando incompleto antes de
|
||||||
|
comparar contra el doc de arquitectura **(reportado)**. `health.log`
|
||||||
|
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
|
||||||
|
05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**.
|
||||||
|
|
||||||
|
**Fix.** Recrear el contenedor con el `docker run` completo de la
|
||||||
|
sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes
|
||||||
|
van juntos, sin excepciones.
|
||||||
|
|
||||||
|
**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el
|
||||||
|
contenedor final (creado `2026-09-26T06:10:55Z`, imagen
|
||||||
|
`tmux-host:2026-09-26-fix-real`) lista los 5 mounts:
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/run/docker.sock -> /var/run/docker.sock
|
||||||
|
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
|
||||||
|
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
|
||||||
|
/DATA -> /DATA
|
||||||
|
/media/HDD-Storage -> /media/HDD-Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
La sesión `lab` retomó la conversación anterior con `--continue`, sin
|
||||||
|
pedir de nuevo la confianza del folder.
|
||||||
|
|
||||||
|
!!! warning "Lección"
|
||||||
|
**Antes de improvisar un `docker run` de memoria, leer siempre el doc
|
||||||
|
de arquitectura que ya existe.** El comando completo y el motivo de
|
||||||
|
cada volumen ya estaban documentados en `sesion-tmux-persistente.md`.
|
||||||
|
El tiempo perdido reconstruyendo con un comando incompleto salió de no
|
||||||
|
abrirlo primero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Causa b — `set -e` heredado mataba al supervisor del healthcheck
|
||||||
|
|
||||||
|
**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo
|
||||||
|
relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un
|
||||||
|
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
|
||||||
|
`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor
|
||||||
|
seguía vivo sin healthcheck.
|
||||||
|
|
||||||
|
**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El
|
||||||
|
supervisor corre como función en un subshell (`supervise_healthcheck &`)
|
||||||
|
y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de
|
||||||
|
cero, `set -e` mataba al propio subshell del supervisor *en esa misma
|
||||||
|
línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban.
|
||||||
|
Es decir, el supervisor moría justo en el único caso para el que existía.
|
||||||
|
|
||||||
|
**Paso en falso: el fix que nunca llegó al disco.** La primera
|
||||||
|
"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la
|
||||||
|
imagen se reconstruyó dos veces con el contenido viejo **(reportado)**.
|
||||||
|
La evidencia sigue en el host **(verificado)**: las etiquetas
|
||||||
|
`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2`
|
||||||
|
apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las
|
||||||
|
capas porque el archivo no había cambiado. Recién después de confirmar
|
||||||
|
con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se
|
||||||
|
construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z).
|
||||||
|
|
||||||
|
**Fix.** En `scripts/tmux-host/entrypoint.sh`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-set -eu
|
||||||
|
+set -u
|
||||||
|
|
||||||
|
-/usr/local/bin/healthcheck.sh &
|
||||||
|
+supervise_healthcheck() {
|
||||||
|
+ while true; do
|
||||||
|
+ /usr/local/bin/healthcheck.sh
|
||||||
|
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
|
||||||
|
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
|
||||||
|
+ sleep 10
|
||||||
|
+ done
|
||||||
|
+}
|
||||||
|
|
||||||
|
+supervise_healthcheck &
|
||||||
|
exec tail -f /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validación en vivo (verificado).** `health.log` registra el supervisor
|
||||||
|
actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró
|
||||||
|
el aviso y se relanzó solo 10 segundos después:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
|
||||||
|
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Lección"
|
||||||
|
**Verificar el archivo en disco (`cat`, `git diff`) ANTES de
|
||||||
|
reconstruir la imagen.** No hay que asumir que el editor guardó el
|
||||||
|
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
|
||||||
|
no tuvo cambios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic
|
||||||
|
|
||||||
|
**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al
|
||||||
|
arrancar con `Unable to connect to Anthropic services` a los ~10 s.
|
||||||
|
|
||||||
|
**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**:
|
||||||
|
|
||||||
|
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
|
||||||
|
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
|
||||||
|
instante con `Network unreachable`.
|
||||||
|
3. El fallback automático a IPv4 terminaba funcionando, pero la conexión
|
||||||
|
completa tardaba **~14 s**. Eso es más que el timeout interno de
|
||||||
|
Claude Code (~10 s), así que el CLI se rendía antes de que el
|
||||||
|
fallback llegara a conectar.
|
||||||
|
|
||||||
|
**Primer intento: parche parcial.**
|
||||||
|
`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera
|
||||||
|
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
|
||||||
|
**(reportado)**, y además solo corregía el proceso de Node, no la red del
|
||||||
|
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
|
||||||
|
`NODE_OPTIONS` en su entorno **(verificado)**.
|
||||||
|
|
||||||
|
**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS,
|
||||||
|
en caliente y persistente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# En caliente
|
||||||
|
sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||||
|
sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||||
|
|
||||||
|
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
|
||||||
|
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
|
||||||
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto se tiene que hacer **en el host, no dentro del contenedor**.
|
||||||
|
`tmux-host` corre con `--network host`, así que comparte el network
|
||||||
|
namespace del NAS. Docker no permite fijar sysctls de red
|
||||||
|
(`--sysctl net.*`) en un contenedor con `--network host`, porque
|
||||||
|
cambiarían la red de todo el host.
|
||||||
|
|
||||||
|
**Validación en vivo (verificado, 2026-09-26 ~06:40Z):**
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
|
||||||
|
net.ipv6.conf.all.disable_ipv6 = 1
|
||||||
|
net.ipv6.conf.default.disable_ipv6 = 1
|
||||||
|
|
||||||
|
$ getent ahosts api.anthropic.com
|
||||||
|
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
|
||||||
|
|
||||||
|
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
|
||||||
|
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/sysctl.conf` en el host contiene las dos líneas (escrito
|
||||||
|
2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura,
|
||||||
|
pero `/etc` es un overlay con capa de escritura en
|
||||||
|
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
|
||||||
|
persiste.
|
||||||
|
|
||||||
|
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
|
||||||
|
|
||||||
|
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
|
||||||
|
Resultado **(verificado)**:
|
||||||
|
|
||||||
|
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
|
||||||
|
(el overlay de `/etc` persiste).
|
||||||
|
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
|
||||||
|
archivo existía y **nunca se aplicó** al arrancar.
|
||||||
|
|
||||||
|
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
|
||||||
|
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
|
||||||
|
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
|
||||||
|
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
|
||||||
|
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
|
||||||
|
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
|
||||||
|
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
|
||||||
|
|
||||||
|
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
|
||||||
|
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
|
||||||
|
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
|
||||||
|
fallando (`Could not connect to server`). Si el router vuelve a entregar
|
||||||
|
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
|
||||||
|
no porque estuviera corregido.
|
||||||
|
|
||||||
|
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
|
||||||
|
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
|
||||||
|
|
||||||
|
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
|
||||||
|
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
|
||||||
|
Hace falta un contenedor privilegiado de un solo uso en la red del host,
|
||||||
|
o `sudo sysctl -p` directo en el host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --privileged --network host \
|
||||||
|
-v /etc/sysctl.d:/etc/sysctl.d:ro \
|
||||||
|
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
|
||||||
|
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
|
||||||
|
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
|
||||||
|
|
||||||
|
!!! warning "Lección"
|
||||||
|
"El archivo de configuración persistió" no es lo mismo que "la
|
||||||
|
configuración se aplica al arrancar". La única validación real de
|
||||||
|
algo persistente es **reiniciar y medir el valor efectivo**, no
|
||||||
|
revisar que el archivo siga ahí.
|
||||||
|
|
||||||
|
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
|
||||||
|
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
|
||||||
|
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
|
||||||
|
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
|
||||||
|
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
|
||||||
|
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
|
||||||
|
`tentative`), que no sale a internet y no afecta.
|
||||||
|
|
||||||
|
Sigue sin verificarse si una **actualización de ZimaOS** pisa
|
||||||
|
`upper_etc`. Después de cada update del SO, revisar
|
||||||
|
`sysctl net.ipv6.conf.all.disable_ipv6`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática
|
||||||
|
|
||||||
|
**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas
|
||||||
|
*seguían* sin aparecer en claude.ai. Este era el síntoma original de la
|
||||||
|
noche.
|
||||||
|
|
||||||
|
**Diagnóstico (verificado).** Ambos procesos `claude --remote-control
|
||||||
|
--continue` estaban corriendo (`ps`), y el healthcheck había recreado
|
||||||
|
las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el
|
||||||
|
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
|
||||||
|
causa:
|
||||||
|
|
||||||
|
- Sesión `lab`: `Login expired · Please run /login`
|
||||||
|
- Sesión `cloud-practice`:
|
||||||
|
`Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control`
|
||||||
|
|
||||||
|
El token OAuth de Claude Code había vencido. No había ningún problema de
|
||||||
|
red, de contenedor ni de volúmenes. **Esta causa no tenía relación con
|
||||||
|
ninguna de las tres anteriores.** Eliminar el contenedor nunca podría
|
||||||
|
haberla resuelto.
|
||||||
|
|
||||||
|
**Fix.**
|
||||||
|
|
||||||
|
1. `/login` en **una** sesión. Las credenciales viven en
|
||||||
|
`/root/.claude/.credentials.json`, que es el volumen compartido por
|
||||||
|
todas las sesiones del contenedor.
|
||||||
|
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
|
||||||
|
abierta. Un proceso `claude` que ya estaba corriendo **no** se
|
||||||
|
reconecta solo al renovarse el token. Hay que reactivarlo en cada uno.
|
||||||
|
Para una sesión a la que no estás adjunto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux send-keys -t cloud-practice '/remote-control' Enter
|
||||||
|
# aparece el menú con "1. Enable Remote Control" ya seleccionado
|
||||||
|
tmux send-keys -t cloud-practice Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a
|
||||||
|
mostrar `/remote-control is active · Continue here, on your phone, or at
|
||||||
|
https://claude.ai/code/session_…`, y en `lab` se activó con
|
||||||
|
`/remote-control` después del `/login`. Las dos sesiones volvieron a
|
||||||
|
aparecer en claude.ai.
|
||||||
|
|
||||||
|
!!! tip "Diferencia clave con las otras causas"
|
||||||
|
El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue`
|
||||||
|
retoma la conversación. Un **token vencido no se recupera solo**: hace
|
||||||
|
falta que una persona se reautentique (`/login`) y reactive Remote
|
||||||
|
Control (`/remote-control`) en cada sesión abierta. Recrear el
|
||||||
|
contenedor no lo arregla. Con los volúmenes bien montados, el
|
||||||
|
contenedor nuevo lee el mismo token vencido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cronología resumida
|
||||||
|
|
||||||
|
Horas en UTC, según `health.log`, `docker images` y los timestamps de
|
||||||
|
archivos cuando existen.
|
||||||
|
|
||||||
|
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|
||||||
|
|---|---|---|
|
||||||
|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — |
|
||||||
|
| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No |
|
||||||
|
| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró |
|
||||||
|
| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No |
|
||||||
|
| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No |
|
||||||
|
| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No |
|
||||||
|
| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No |
|
||||||
|
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No |
|
||||||
|
| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado |
|
||||||
|
| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** |
|
||||||
|
|
||||||
|
**Camino corto para la próxima vez.** Si "no aparecen las sesiones
|
||||||
|
remotas", **antes de tocar el contenedor** mirar el pane de cada sesión:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Si aparece `Login expired` u `OAuth token unavailable`, la solución es
|
||||||
|
`/login` + `/remote-control`. No hace falta nada más.
|
||||||
|
|
||||||
|
## Lección general
|
||||||
|
|
||||||
|
Un solo síntoma ("no toma las sesiones remotas") puede tener **varias
|
||||||
|
causas independientes superpuestas**. En este caso, tres de ellas las
|
||||||
|
provocó el propio intento de arreglo. Que la primera causa real
|
||||||
|
encontrada se haya resuelto no significa que era *la* causa del síntoma.
|
||||||
|
|
||||||
|
Lo que destrabó el incidente fue seguir verificando con evidencia
|
||||||
|
concreta después de cada fix, en vez de asumir que "ya quedó": el
|
||||||
|
contenido de los panes, `health.log`, `docker inspect`, `docker images`
|
||||||
|
(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó
|
||||||
|
con un dato observable, no por deducción.
|
||||||
|
|
||||||
|
## Pendientes
|
||||||
|
|
||||||
|
Todos cerrados el 2026-09-26:
|
||||||
|
|
||||||
|
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
|
||||||
|
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
|
||||||
|
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
|
||||||
|
`fix-real` en producción.
|
||||||
|
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
|
||||||
|
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
|
||||||
|
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
|
||||||
|
que ahora sí se aplica solo (ver la causa c).
|
||||||
|
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
|
||||||
|
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
|
||||||
|
|
||||||
|
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
|
||||||
|
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
|
||||||
|
y que `disable_ipv6` siga en `1`.
|
||||||
@@ -86,6 +86,7 @@ nav:
|
|||||||
- Flujo GitOps: arquitectura/gitops-flujo.md
|
- Flujo GitOps: arquitectura/gitops-flujo.md
|
||||||
- Agente de monitoreo: arquitectura/agente-monitoreo.md
|
- Agente de monitoreo: arquitectura/agente-monitoreo.md
|
||||||
- Sesión tmux persistente: arquitectura/sesion-tmux-persistente.md
|
- Sesión tmux persistente: arquitectura/sesion-tmux-persistente.md
|
||||||
|
- Plugins y subagentes: arquitectura/plugins-y-subagentes.md
|
||||||
- Glosario: arquitectura/glosario.md
|
- Glosario: arquitectura/glosario.md
|
||||||
- Decisiones (ADRs):
|
- Decisiones (ADRs):
|
||||||
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
|
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
|
||||||
@@ -97,6 +98,10 @@ nav:
|
|||||||
- "0007 · Identidad visual propia": decisiones/0007-identidad-visual-propia.md
|
- "0007 · Identidad visual propia": decisiones/0007-identidad-visual-propia.md
|
||||||
- "0008 · Identidad Navy & Coral": decisiones/0008-identidad-navy-coral.md
|
- "0008 · Identidad Navy & Coral": decisiones/0008-identidad-navy-coral.md
|
||||||
- "0009 · Checkout real": decisiones/0009-checkout-real.md
|
- "0009 · Checkout real": decisiones/0009-checkout-real.md
|
||||||
|
- "0010 · Pago y envío reales": decisiones/0010-pago-envio-reales-y-storefront.md
|
||||||
|
- "0011 · Ambiente de Staging": decisiones/0011-ambiente-staging.md
|
||||||
|
- "0012 · Práctica multi-cloud GCP/Azure": decisiones/0012-practica-multicloud-gcp-azure.md
|
||||||
|
- "0013 · Iniciativas de clientes": decisiones/0013-iniciativas-clientes.md
|
||||||
- Plantilla ADR: decisiones/plantilla-adr.md
|
- Plantilla ADR: decisiones/plantilla-adr.md
|
||||||
- Playbooks:
|
- Playbooks:
|
||||||
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
||||||
@@ -109,6 +114,7 @@ nav:
|
|||||||
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
|
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
|
||||||
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
|
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
|
||||||
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
|
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
|
||||||
|
- "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md
|
||||||
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
|
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
|
||||||
- Aprendizajes:
|
- Aprendizajes:
|
||||||
- Notas sueltas: aprendizajes/notas-sueltas.md
|
- Notas sueltas: aprendizajes/notas-sueltas.md
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.207
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.294
|
||||||
# El base saca TODAS las capabilities. Probado en vivo y
|
# El base saca TODAS las capabilities. Probado en vivo y
|
||||||
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
|
||||||
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: ecommerce-staging
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- https://gitea.cruzcloud.net/devops/platform-templates.git//k8s-manifests/base?ref=main
|
||||||
|
|
||||||
|
patches:
|
||||||
|
# Mismo criterio que workloads/ecommerce/frontend/kustomization.yaml:
|
||||||
|
# sin anchors/aliases de YAML a propósito (el parser de Gitea Actions no
|
||||||
|
# los resuelve en "on:", pero acá aplica igual por consistencia con el
|
||||||
|
# resto del repo). Ingress propio via ingress-staging.yaml en vez del
|
||||||
|
# generico de 1-backend del base.
|
||||||
|
- target:
|
||||||
|
kind: Ingress
|
||||||
|
name: app-ingress
|
||||||
|
patch: |-
|
||||||
|
$patch: delete
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: app-ingress
|
||||||
|
- target:
|
||||||
|
kind: Deployment
|
||||||
|
name: app-deployment
|
||||||
|
path: patches/deployment.yaml
|
||||||
|
- target:
|
||||||
|
kind: Service
|
||||||
|
name: app-svc
|
||||||
|
path: patches/service.yaml
|
||||||
|
|
||||||
|
- target:
|
||||||
|
kind: Deployment
|
||||||
|
name: app-deployment
|
||||||
|
patch: |-
|
||||||
|
- op: replace
|
||||||
|
path: /metadata/name
|
||||||
|
value: frontend-deploy
|
||||||
|
- op: replace
|
||||||
|
path: /spec/template/spec/containers/0/ports
|
||||||
|
value:
|
||||||
|
- name: http
|
||||||
|
containerPort: 80
|
||||||
|
- target:
|
||||||
|
kind: Service
|
||||||
|
name: app-svc
|
||||||
|
patch: |-
|
||||||
|
- op: replace
|
||||||
|
path: /metadata/name
|
||||||
|
value: frontend-svc
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: app-deployment
|
||||||
|
labels:
|
||||||
|
app: frontend
|
||||||
|
spec:
|
||||||
|
# 1 réplica (no 2 como Producción): Staging es para verificar cambios
|
||||||
|
# antes de promover, no para servir tráfico real -- alcanza, y deja más
|
||||||
|
# margen de recursos en la NAS para el resto del cluster.
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: frontend
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 1001
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
|
||||||
|
env:
|
||||||
|
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
|
||||||
|
- name: APP_VERSION
|
||||||
|
value: v1.0.293
|
||||||
|
- name: HEADLESS_PROVIDER
|
||||||
|
value: medusa
|
||||||
|
# Backend COMPARTIDO con Producción a propósito (decisión del
|
||||||
|
# usuario 2026-09-14: armar un backend propio para Staging
|
||||||
|
# -Postgres/Medusa/Meilisearch- duplicaría el consumo de la
|
||||||
|
# NAS sin necesidad todavía). FQDN cross-namespace: "ecommerce"
|
||||||
|
# es el namespace real de medusa-svc -- el nombre corto
|
||||||
|
# "medusa-svc" solo resuelve dentro del mismo namespace.
|
||||||
|
- name: MEDUSA_BACKEND_URL
|
||||||
|
value: http://medusa-svc.ecommerce.svc.cluster.local:9000
|
||||||
|
- name: MEDUSA_REGION_ID
|
||||||
|
value: reg_01KZT86WPAH7A7ZZRDSX3350V2
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: commerce-storefront
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
# Mismo securityContext que Producción -- ver comentario largo en
|
||||||
|
# workloads/ecommerce/frontend/patches/deployment.yaml (misma
|
||||||
|
# imagen, mismos motivos: runAsUser numérico por el Dockerfile
|
||||||
|
# usando "nextjs" por nombre, NET_BIND_SERVICE por el puerto 80,
|
||||||
|
# volumenes por el cache de Next.js con root filesystem de solo
|
||||||
|
# lectura).
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
# Requests/limits mas chicos que Produccion -- una sola replica
|
||||||
|
# de verificacion, no trafico real.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: next-cache
|
||||||
|
mountPath: /app/.next/cache
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
volumes:
|
||||||
|
- name: next-cache
|
||||||
|
emptyDir: {}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: app-svc
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: frontend
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: ecommerce-staging-ingress
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: staging.cruzcloud.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: frontend-svc
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: ecommerce-staging
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- frontend/
|
||||||
|
- ingress-staging.yaml
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { completeCart } from "@/lib/headless/providers/medusa-payment";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { cartId } = await request.json();
|
||||||
|
if (!cartId) {
|
||||||
|
return Response.json({ error: "Falta cartId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await completeCart(cartId);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getPseBanks } from "@/lib/headless/providers/medusa-payment";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const banks = await getPseBanks();
|
||||||
|
return Response.json({ banks });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createPaymentCollection, createPsePaymentSession } from "@/lib/headless/providers/medusa-payment";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { cartId, ...pse } = await request.json();
|
||||||
|
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.firstName || !pse.lastName || !pse.email || !pse.phone) {
|
||||||
|
return Response.json({ error: "Faltan campos obligatorios para el pago con PSE" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const paymentCollectionId = await createPaymentCollection(cartId);
|
||||||
|
const bankUrl = await createPsePaymentSession(paymentCollectionId, pse);
|
||||||
|
return Response.json({ bankUrl });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { handleGoogleCallback } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { code, state } = await request.json();
|
||||||
|
if (!code || !state) {
|
||||||
|
return Response.json({ error: "Faltan parámetros de Google" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await handleGoogleCallback(code, state);
|
||||||
|
return Response.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getGoogleAuthUrl } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
// Navegacion de pagina completa (no fetch/XHR) -- el boton "Continuar
|
||||||
|
// con Google" enlaza directo aca, el browser sigue el 302 hasta la
|
||||||
|
// pantalla real de consentimiento de Google.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const location = await getGoogleAuthUrl();
|
||||||
|
return Response.redirect(location);
|
||||||
|
} catch (error) {
|
||||||
|
const message = encodeURIComponent((error as Error).message);
|
||||||
|
return Response.redirect(new URL(`/?googleAuthError=${message}`, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { loginCustomer } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { email, password } = await request.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return Response.json({ error: "Correo y contraseña son obligatorios" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await loginCustomer(email, password);
|
||||||
|
return Response.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getCurrentCustomer } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
|
if (!token) {
|
||||||
|
return Response.json({ error: "token es obligatorio" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await getCurrentCustomer(token);
|
||||||
|
return Response.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||||
|
try {
|
||||||
|
return Response.json({ orders: await getMyOrders(header.slice(7)) });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { registerCustomer } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { email, password, firstName, lastName } = await request.json();
|
||||||
|
if (!email || !password || !firstName || !lastName) {
|
||||||
|
return Response.json({ error: "Faltan campos obligatorios" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await registerCustomer(email, password, firstName, lastName);
|
||||||
|
return Response.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||||
|
try {
|
||||||
|
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
function bearer(request: Request): string | null {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
return header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const productId = new URL(request.url).searchParams.get("product_id");
|
||||||
|
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
|
||||||
|
try {
|
||||||
|
return Response.json(await getProductReviews(productId));
|
||||||
|
} catch {
|
||||||
|
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
|
||||||
|
// reseñas" en vez de romperse.
|
||||||
|
return Response.json({ reviews: [], average: 0, count: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const token = bearer(request);
|
||||||
|
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
try {
|
||||||
|
return Response.json(await createReview(token, body), { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number }).status ?? 400;
|
||||||
|
return Response.json({ error: (error as Error).message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
return Response.json({ summary: await getReviewSummary() });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ summary: {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { GoogleCallbackClient } from "@/components/account/google-callback-client";
|
||||||
|
|
||||||
|
export default function GoogleCallbackPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<GoogleCallbackClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { MessageCircle, PackageOpen, ShieldCheck, Truck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Cambios | ARI Shopping" };
|
||||||
|
|
||||||
|
const WHATSAPP_URL = "https://wa.me/573016138155";
|
||||||
|
|
||||||
|
export default function CambiosPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Cambios</h1>
|
||||||
|
<p className="mt-5 text-lg leading-8 text-slate-600">
|
||||||
|
Si tu pedido no es lo que esperabas, te ayudamos a cambiarlo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-6 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<PackageOpen className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">5 días hábiles</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Tenés 5 días hábiles desde que recibís tu pedido para solicitar un cambio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<Truck className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Envío de vuelta sin costo</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
ARI Shopping asume el costo del envío de vuelta del producto — no tenés que pagar nada extra.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<ShieldCheck className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Condiciones</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
El producto debe estar sin usar, con su empaque y etiquetas originales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<MessageCircle className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">¿Cómo lo pido?</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Escribinos por WhatsApp con tu número de pedido y coordinamos la recolección y el cambio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="primary" size="lg" className="mt-10">
|
||||||
|
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
Solicitar un cambio por WhatsApp
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ type CatalogPageProps = {
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sin tildes/eñes ni mayusculas: los links viejos (o escritos a mano)
|
||||||
|
// como ?category=Munecas y peluches siguen llevando a la categoria real
|
||||||
|
// "Muñecas y peluches" (renombrada con tildes el 2026-09-27).
|
||||||
|
function normalizeName(value: string): string {
|
||||||
|
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function single(value: string | string[] | undefined): string {
|
function single(value: string | string[] | undefined): string {
|
||||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||||
}
|
}
|
||||||
@@ -19,9 +26,16 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
||||||
|
|
||||||
const category = single(params.category) || "Todos";
|
const requestedCategory = single(params.category) || "Todos";
|
||||||
|
const category =
|
||||||
|
categories.find((node) => normalizeName(node.name) === normalizeName(requestedCategory))?.name ?? requestedCategory;
|
||||||
const collection = single(params.collection);
|
const collection = single(params.collection);
|
||||||
const query = single(params.q);
|
const query = single(params.q);
|
||||||
|
const taxonomy = {
|
||||||
|
franquicia: single(params.franquicia),
|
||||||
|
saga: single(params.saga),
|
||||||
|
personaje: single(params.personaje),
|
||||||
|
};
|
||||||
|
|
||||||
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
|
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
|
||||||
// morrales"), pero ningun producto tiene esa categoria asignada
|
// morrales"), pero ningun producto tiene esa categoria asignada
|
||||||
@@ -39,13 +53,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CatalogClient
|
<CatalogClient
|
||||||
key={`${category}:${collection}:${query}`}
|
key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
|
||||||
products={products}
|
products={products}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
initialCategory={category}
|
initialCategory={category}
|
||||||
initialCollection={collection}
|
initialCollection={collection}
|
||||||
initialQuery={query}
|
initialQuery={query}
|
||||||
|
initialTaxonomy={taxonomy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { PseReturnClient } from "@/components/checkout/pse-return-client";
|
||||||
|
|
||||||
|
export default function PseReturnPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PseReturnClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Mail, MapPin, MessageCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Contacto | ARI Shopping" };
|
||||||
|
|
||||||
|
// Numero real confirmado en vivo (probando el boton "Confirmar por
|
||||||
|
// WhatsApp" del checkout con navegador real): mismo canal que ya usan
|
||||||
|
// los clientes para confirmar pedidos, consistencia a proposito.
|
||||||
|
const WHATSAPP_URL = "https://wa.me/573016138155";
|
||||||
|
// Correo corporativo nuevo ([email protected] -> reenviado por
|
||||||
|
// Cloudflare Email Routing a la casilla real) -- ver nota de
|
||||||
|
// implementacion en ADR 0010 para el paso manual de DNS pendiente.
|
||||||
|
const CONTACT_EMAIL = "[email protected]";
|
||||||
|
|
||||||
|
export default function ContactoPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-2xl px-4 py-14 text-center lg:py-20">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Contacto</h1>
|
||||||
|
<p className="mt-5 text-lg leading-8 text-slate-600">
|
||||||
|
¿Tenés una pregunta sobre tu pedido, un producto o un cambio? Escribinos, te respondemos lo antes posible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 sm:grid-cols-2">
|
||||||
|
<a
|
||||||
|
href={WHATSAPP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 p-6 transition hover:border-ari-marino hover:bg-ari-marino/5"
|
||||||
|
>
|
||||||
|
<MessageCircle className="size-7 text-ari-marino" />
|
||||||
|
<div>
|
||||||
|
<p className="font-black">WhatsApp</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">+57 301 613 8155</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 p-6 transition hover:border-ari-marino hover:bg-ari-marino/5"
|
||||||
|
>
|
||||||
|
<Mail className="size-7 text-ari-marino" />
|
||||||
|
<div>
|
||||||
|
<p className="font-black">Correo</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{CONTACT_EMAIL}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex items-center justify-center gap-2 text-sm text-slate-500">
|
||||||
|
<MapPin className="size-4" />
|
||||||
|
Despachamos desde Santiago de Cali, Colombia
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="primary" size="lg" className="mt-10">
|
||||||
|
<a href={WHATSAPP_URL} target="_blank" rel="noopener noreferrer">
|
||||||
|
<MessageCircle className="size-5" />
|
||||||
|
Escribirnos por WhatsApp
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OrdersClient } from "@/components/account/orders-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Mis pedidos",
|
||||||
|
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
|
||||||
|
robots: { index: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return <OrdersClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Calculator, Clock, MapPinned, Truck } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Envíos | ARI Shopping" };
|
||||||
|
|
||||||
|
// ADR 0010 §2: el costo de envio se calcula en vivo con EnvioClick (peso +
|
||||||
|
// direccion), no es un valor fijo -- el copy de abajo describe ese
|
||||||
|
// comportamiento real, no un placeholder. Transportadoras reales
|
||||||
|
// confirmadas en una cotizacion en vivo contra la API de EnvioClick
|
||||||
|
// (PIBOX, COORDINADORA, ENVIA, RAPPI, TCC) -- EnvioClick cotiza entre
|
||||||
|
// ellas y el sistema toma automaticamente la mas economica por pedido.
|
||||||
|
const CARRIERS = ["Coordinadora", "Envía", "TCC", "Rappi", "Pibox"];
|
||||||
|
|
||||||
|
export default function EnviosPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Envíos</h1>
|
||||||
|
<p className="mt-5 text-lg leading-8 text-slate-600">
|
||||||
|
Hacemos envíos a nivel nacional desde nuestra bodega en Santiago de Cali, Valle del Cauca
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-6 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<Calculator className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Costo calculado en tiempo real</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
El valor del envío se calcula automáticamente según el peso de tu pedido y la dirección de entrega —
|
||||||
|
lo ves exacto en el checkout antes de pagar, nunca es una tarifa a ciegas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<Truck className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Transportadoras aliadas</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Cotizamos automáticamente entre {CARRIERS.join(", ")} y asignamos la tarifa más conveniente para cada
|
||||||
|
pedido.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<Clock className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Tiempos de entrega</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Varían según la transportadora asignada y tu ciudad — una vez despachado tu pedido te compartimos el
|
||||||
|
número de guía para hacerle seguimiento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6">
|
||||||
|
<MapPinned className="size-6 text-ari-marino" />
|
||||||
|
<h2 className="mt-3 font-black">Cobertura</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Envíos a todo el territorio nacional colombiano desde nuestra bodega en Santiago de Cali, Valle del Cauca
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-10 text-sm text-slate-500">
|
||||||
|
¿Tenés dudas puntuales sobre un pedido? Escribinos por{" "}
|
||||||
|
<a href="/contacto" className="font-bold text-ari-marino hover:underline">
|
||||||
|
nuestros canales de contacto
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,17 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
||||||
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
|
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
|
||||||
return <><HeroCarousel /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
|
// ADR 0010: el banner toma primero productos destacados (curaduria
|
||||||
|
// explicita del admin via el flag "featured"); si no hay ninguno,
|
||||||
|
// cae a los mismos "tesoros" (nuevos/destacados) de la seccion de
|
||||||
|
// abajo para no repetir la logica de seleccion. Se exige ademas
|
||||||
|
// "available" -- encontrado probando en vivo con un navegador real
|
||||||
|
// (Playwright): un producto featured pero sin stock disponible salio
|
||||||
|
// primero en el carrusel con boton "Comprar ahora" hacia una pagina
|
||||||
|
// donde en realidad no se puede comprar.
|
||||||
|
const featuredProducts = products.filter((product) => product.featured && product.available);
|
||||||
|
const bannerProducts = featuredProducts.length > 0 ? featuredProducts : treasures.filter((product) => product.available);
|
||||||
|
return <><HeroCarousel products={bannerProducts} /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
|
||||||
asset nuevo -- se reconstruye el mismo efecto (fondo punteado +
|
asset nuevo -- se reconstruye el mismo efecto (fondo punteado +
|
||||||
fotos estilo polaroid) con CSS y fotos reales de producto ya
|
fotos estilo polaroid) con CSS y fotos reales de producto ya
|
||||||
existentes, en azul/amarillo de marca en vez del violeta original
|
existentes, en azul/amarillo de marca en vez del violeta original
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Preguntas frecuentes | ARI Shopping" };
|
||||||
|
|
||||||
|
// Solo preguntas cuya respuesta es un hecho real del storefront (ver
|
||||||
|
// ADR 0009/0010) -- nada de metodos de pago, plazos u opciones que no
|
||||||
|
// esten realmente implementados.
|
||||||
|
const FAQS: { question: string; answer: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
question: "¿Qué métodos de pago aceptan?",
|
||||||
|
answer: "Pagás por PSE, con transferencia directa desde tu banco. Podés pagar como persona natural o jurídica.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "¿A qué ciudades hacen envíos?",
|
||||||
|
answer: "A todo el territorio nacional colombiano. El costo del envío se calcula automáticamente según tu dirección y lo ves en el checkout antes de pagar.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "¿Cuánto tarda en llegar mi pedido?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
Depende de la transportadora asignada y tu ciudad — más detalle en{" "}
|
||||||
|
<Link href="/envios" className="font-bold text-ari-marino hover:underline">
|
||||||
|
Envíos
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "¿Puedo cambiar un producto que no me sirvió?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
Sí, tenés 5 días hábiles desde que lo recibís y nosotros asumimos el envío de vuelta — más detalle en{" "}
|
||||||
|
<Link href="/cambios" className="font-bold text-ari-marino hover:underline">
|
||||||
|
Cambios
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Tengo otra pregunta, ¿cómo los contacto?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
Escribinos por WhatsApp o correo desde{" "}
|
||||||
|
<Link href="/contacto" className="font-bold text-ari-marino hover:underline">
|
||||||
|
Contacto
|
||||||
|
</Link>
|
||||||
|
, te respondemos lo antes posible.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PreguntasFrecuentesPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-14 lg:py-20">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Ayuda</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Preguntas frecuentes</h1>
|
||||||
|
<Accordion type="single" collapsible className="mt-10">
|
||||||
|
{FAQS.map((faq) => (
|
||||||
|
<AccordionItem key={faq.question} value={faq.question}>
|
||||||
|
<AccordionTrigger>{faq.question}</AccordionTrigger>
|
||||||
|
<AccordionContent>{faq.answer}</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
|
import { CardRating } from "@/components/product/card-rating";
|
||||||
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
|
import { ProductReviews } from "@/components/product/product-reviews";
|
||||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||||
import { ProductGallery } from "@/components/product/product-gallery";
|
import { ProductGallery } from "@/components/product/product-gallery";
|
||||||
import { ProductPurchase } from "@/components/product/product-purchase";
|
import { ProductPurchase } from "@/components/product/product-purchase";
|
||||||
@@ -24,7 +26,17 @@ export const dynamic = "force-dynamic";
|
|||||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const product = await getProductBySlug(slug);
|
const product = await getProductBySlug(slug);
|
||||||
return product ? { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } } : { title: "Producto" };
|
// notFound() se llama aquí (no solo en el componente de abajo) porque
|
||||||
|
// generateMetadata se resuelve por completo ANTES de que Next empiece a
|
||||||
|
// transmitir la respuesta -- el <Suspense> del navbar (categorías, ver
|
||||||
|
// navbar.tsx) hace que el layout raíz empiece a stream-ear con status 200
|
||||||
|
// antes de que un notFound() lanzado dentro del componente de la página
|
||||||
|
// pueda cambiarlo (confirmado en vivo: cualquier slug inexistente devolvía
|
||||||
|
// HTTP 200 con el contenido de app/not-found.tsx, rompiendo SEO/crawlers
|
||||||
|
// que sí miran el status code). Llamarlo aquí evita el problema porque esta
|
||||||
|
// fase bloquea el stream por diseño.
|
||||||
|
if (!product) notFound();
|
||||||
|
return { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
@@ -33,5 +45,86 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
const products = await getProducts();
|
const products = await getProducts();
|
||||||
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
|
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
|
||||||
return <><section className="mx-auto grid max-w-7xl gap-10 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16"><ProductGallery images={product.images} name={product.name} /><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span><h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1><p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p><div className="mt-8"><ProductPurchase product={product} /></div><Accordion type="single" collapsible className="mt-8"><AccordionItem value="details"><AccordionTrigger>Detalles del producto</AccordionTrigger><AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent></AccordionItem><AccordionItem value="shipping"><AccordionTrigger>Envíos y entregas</AccordionTrigger><AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent></AccordionItem></Accordion></div></section>{recommended.length > 0 && <section className="mx-auto max-w-7xl px-4 pb-24"><h2 className="mb-7 text-3xl font-black">También te puede gustar</h2><ProductCarousel products={recommended} /></section>}<MobileAddToCart product={product} /></>;
|
// Categoría distinta a propósito -- antes repetía los primeros 2 del
|
||||||
|
// mismo "recommended" que ya alimenta el carrusel de abajo (mismos
|
||||||
|
// productos en dos lugares de la página, reportado por el usuario).
|
||||||
|
// Un cross-sell real de otra categoría aporta algo que el carrusel no
|
||||||
|
// muestra, en vez de duplicarlo.
|
||||||
|
const crossCategory = products.filter((candidate) => candidate.id !== product.id && candidate.category !== product.category);
|
||||||
|
const crossSell = crossCategory.find((candidate) => candidate.available) ?? crossCategory[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* max-w-[1600px] (antes max-w-7xl/1280px) y una 3ra columna desde
|
||||||
|
xl -- aprovecha pantallas grandes en vez de dejar el buy box
|
||||||
|
apretado contra el texto o cortado por el panel de zoom de al
|
||||||
|
lado (ver ProductGallery). Por debajo de xl, el buy box cae al
|
||||||
|
flujo normal en la 2da columna, sin duplicar el componente en
|
||||||
|
el DOM.
|
||||||
|
El buy box ocupa las 2 filas (xl:row-span-2) para no quedar
|
||||||
|
atado a la altura de galería+descripción -- "También te puede
|
||||||
|
gustar" pasa a ser la 2da fila debajo de esas dos columnas
|
||||||
|
(xl:col-span-2), en paralelo al buy box sticky, en vez de
|
||||||
|
esperar a que termine la columna más alta antes de aparecer. */}
|
||||||
|
<section className="mx-auto grid max-w-[1600px] gap-x-10 gap-y-12 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16 xl:grid-cols-[1.1fr_.75fr_360px] xl:grid-rows-[auto_auto]">
|
||||||
|
<ProductGallery images={product.images} name={product.name} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
|
||||||
|
<CardRating productId={product.id} href="#resenas" />
|
||||||
|
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
|
||||||
|
{/* Antes escondido detrás de un click en el acordeón --
|
||||||
|
siempre visible, estilo Amazon, con los mismos datos
|
||||||
|
reales que ya existían (marca/edad/categoría/colección),
|
||||||
|
sin inventar specs que el catálogo no tiene. */}
|
||||||
|
<h2 className="mb-4 mt-8 text-sm font-black uppercase tracking-wide text-slate-500">Detalles del producto</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-2xl border border-slate-200 p-5 text-sm sm:grid-cols-4">
|
||||||
|
<div><dt className="text-slate-400">Marca</dt><dd className="mt-0.5 font-bold text-slate-900">{product.brand}</dd></div>
|
||||||
|
<div><dt className="text-slate-400">Edad recomendada</dt><dd className="mt-0.5 font-bold text-slate-900">{product.ageRange}</dd></div>
|
||||||
|
<div><dt className="text-slate-400">Categoría</dt><dd className="mt-0.5 font-bold text-slate-900">{product.category}</dd></div>
|
||||||
|
<div><dt className="text-slate-400">Colección</dt><dd className="mt-0.5 font-bold text-slate-900">{product.collection}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<Accordion type="single" collapsible className="mt-6">
|
||||||
|
<AccordionItem value="shipping">
|
||||||
|
<AccordionTrigger>Envíos y entregas</AccordionTrigger>
|
||||||
|
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
<ProductReviews productId={product.id} />
|
||||||
|
</div>
|
||||||
|
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
|
||||||
|
tarjeta, sticky desde xl mientras el resto de la pagina
|
||||||
|
scrollea. El cross-sell es de OTRA categoría a propósito --
|
||||||
|
evita repetir lo que ya muestra el carrusel de "También te
|
||||||
|
puede gustar" de abajo (mismo criterio de categoría/colección
|
||||||
|
que ese carrusel), aportando un producto genuinamente distinto
|
||||||
|
en vez de duplicado. */}
|
||||||
|
<div className="min-w-0 xl:sticky xl:top-24 xl:row-span-2 xl:self-start">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<ProductPurchase product={product} />
|
||||||
|
</div>
|
||||||
|
{crossSell && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2>
|
||||||
|
<ProductCard product={crossSell} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* min-w-0 es necesario: sin esto, el carrusel horizontal
|
||||||
|
(overflow-x-auto interno) contaba como item de este grid, y su
|
||||||
|
ancho minimo intrinseco (la suma de TODAS las cards sin
|
||||||
|
wrappear, ya que el scroller no tiene flex-wrap) empujaba todo
|
||||||
|
el grid completo a ~800px en mobile -- afectaba a TODA la
|
||||||
|
columna compartida (imagen, titulo, specs), no solo al
|
||||||
|
carrusel. Confirmado en vivo: sin min-w-0 el grid medía 783px
|
||||||
|
en un viewport real de 380px; con min-w-0 vuelve a 380px. */}
|
||||||
|
{recommended.length > 0 && (
|
||||||
|
<div className="min-w-0 pb-16 lg:col-span-2">
|
||||||
|
<h2 className="mb-7 text-3xl font-black">También te puede gustar</h2>
|
||||||
|
<ProductCarousel products={recommended} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -24,3 +24,28 @@ data:
|
|||||||
# env var del plugin, no del servidor Meilisearch en si -- ver
|
# env var del plugin, no del servidor Meilisearch en si -- ver
|
||||||
# MEILI_MASTER_KEY en secrets.template.yaml).
|
# MEILI_MASTER_KEY en secrets.template.yaml).
|
||||||
MEILISEARCH_HOST: http://meilisearch-svc:7700
|
MEILISEARCH_HOST: http://meilisearch-svc:7700
|
||||||
|
|
||||||
|
# ePayco (ADR 0009 -- tercer proveedor evaluado, ver medusa-config.ts
|
||||||
|
# para el historial completo) -- solo URLs/config no sensible,
|
||||||
|
# credenciales reales (publicKey/privateKey/pCustIdCliente/pKey) van en
|
||||||
|
# secrets.template.yaml. EPAYCO_ENV=sandbox hasta que haya credenciales
|
||||||
|
# de produccion reales.
|
||||||
|
EPAYCO_ENV: sandbox
|
||||||
|
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
|
||||||
|
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
|
||||||
|
|
||||||
|
# EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen,
|
||||||
|
# reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY
|
||||||
|
# va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint
|
||||||
|
# de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa
|
||||||
|
# tarifas reales, no tiene modo sandbox. Origen = bodega Santiago de
|
||||||
|
# Cali, Valle del Cauca ("Bodega principal ARI Shopping") -- corregido
|
||||||
|
# 2026-09-12: el código DANE cargado originalmente era el de Bogotá
|
||||||
|
# (11001000) por error; toda cotización real hecha antes de esta
|
||||||
|
# fecha calculó la tarifa desde el origen equivocado.
|
||||||
|
ENVIOCLICK_SANDBOX: "true"
|
||||||
|
ENVIOCLICK_ORIGIN_DANE_CODE: "76001000"
|
||||||
|
ENVIOCLICK_ORIGIN_ADDRESS: "Cra 100 #42-65"
|
||||||
|
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
|
||||||
|
ENVIOCLICK_ORIGIN_EMAIL: "[email protected]"
|
||||||
|
ENVIOCLICK_ORIGIN_PHONE: "3014750823"
|
||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.292
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- node
|
- node
|
||||||
@@ -86,7 +86,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.168
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.292
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ stringData:
|
|||||||
# commerce-storefront: el subscriber de Medusa lo envía como header
|
# commerce-storefront: el subscriber de Medusa lo envía como header
|
||||||
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
|
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
|
||||||
REVALIDATE_SECRET: CAMBIAR
|
REVALIDATE_SECRET: CAMBIAR
|
||||||
|
|
||||||
|
# ePayco (ADR 0009 -- tercer proveedor evaluado: PayU LatAm quedó
|
||||||
|
# inviable porque Rapyd compró ese negocio en 2023, Rapyd descartado
|
||||||
|
# por costo; ePayco es de Davivienda desde 2024, mejor tarifa PSE para
|
||||||
|
# el volumen real del usuario). Credenciales del comercio real, nunca
|
||||||
|
# hardcodeadas acá. p_cust_id_cliente/p_key son valores propios del
|
||||||
|
# dashboard de ePayco (Configuración > Llaves secretas), distintos de
|
||||||
|
# publicKey/privateKey.
|
||||||
|
EPAYCO_PUBLIC_KEY: CAMBIAR
|
||||||
|
EPAYCO_PRIVATE_KEY: CAMBIAR
|
||||||
|
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
|
||||||
|
EPAYCO_P_KEY: CAMBIAR
|
||||||
|
|
||||||
|
# EnvíoClick (ADR 0009 §2) -- API key real de la cuenta
|
||||||
|
# (envioclickpro.com.co > API Key / Desarrolladores).
|
||||||
|
ENVIOCLICK_API_KEY: CAMBIAR
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LogOut, MessageCircle, PackageCheck } from "lucide-react";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
|
|
||||||
|
type Mode = "login" | "register";
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" className="size-4" aria-hidden="true">
|
||||||
|
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.7-6.1 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21 21-9.4 21-21c0-1.4-.1-2.7-.4-3.5z" />
|
||||||
|
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.9 18.9 13 24 13c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 16.1 3 9.3 7.6 6.3 14.7z" />
|
||||||
|
<path fill="#4CAF50" d="M24 45c5.2 0 9.9-2 13.4-5.2l-6.2-5.2c-2 1.5-4.6 2.4-7.2 2.4-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.2 40.3 16 45 24 45z" />
|
||||||
|
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.2 4.3-4.1 5.6l6.2 5.2C41.4 35.9 45 30.5 45 24c0-1.4-.1-2.7-.4-3.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
|
||||||
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
|
const [form, setForm] = useState({ email: "", password: "", firstName: "", lastName: "" });
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const ok = mode === "login"
|
||||||
|
? await login(form.email, form.password)
|
||||||
|
: await register(form.email, form.password, form.firstName, form.lastName);
|
||||||
|
if (ok) setForm({ email: "", password: "", firstName: "", lastName: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = hasHydrated && customer !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg p-7">
|
||||||
|
<div className="pr-10">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Tu cuenta</span>
|
||||||
|
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
|
||||||
|
<div className="mt-6 grid gap-3">
|
||||||
|
<Button asChild variant="outline"><Link href="/cuenta/pedidos" onClick={() => onOpenChange(false)}><PackageCheck />Mis pedidos y reseñas</Link></Button>
|
||||||
|
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
|
||||||
|
<h2 className="mt-3 text-3xl font-black">{mode === "login" ? "Inicia sesión" : "Crea tu cuenta"}</h2>
|
||||||
|
<form onSubmit={submit} className="mt-5 space-y-3">
|
||||||
|
{mode === "register" && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input placeholder="Nombre" required value={form.firstName} onChange={(event) => setForm({ ...form, firstName: event.target.value })} />
|
||||||
|
<Input placeholder="Apellido" required value={form.lastName} onChange={(event) => setForm({ ...form, lastName: event.target.value })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
|
||||||
|
<Input type="password" placeholder="Contraseña" required minLength={8} value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
|
||||||
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
||||||
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||||
|
<span className="h-px flex-1 bg-slate-100" />o<span className="h-px flex-1 bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="lg" className="mt-3 w-full">
|
||||||
|
<a href="/api/customer/google/start">
|
||||||
|
<GoogleIcon />Continuar con Google
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
|
||||||
|
onClick={() => setMode(mode === "login" ? "register" : "login")}
|
||||||
|
>
|
||||||
|
{mode === "login" ? "¿No tienes cuenta? Regístrate" : "¿Ya tienes cuenta? Inicia sesión"}
|
||||||
|
</button>
|
||||||
|
<div className="mt-6 border-t border-slate-100 pt-5">
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer">
|
||||||
|
<MessageCircle />Hablar con ARI
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
|
|
||||||
|
export function GoogleCallbackClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const loginWithGoogle = useCustomerStore((state) => state.loginWithGoogle);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const ran = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Guard contra el doble-mount de StrictMode -- el code de Google es
|
||||||
|
// de un solo uso, un segundo intento con el mismo code fallaria.
|
||||||
|
if (ran.current) return;
|
||||||
|
ran.current = true;
|
||||||
|
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
if (!code || !state) {
|
||||||
|
setError("Faltan parámetros de Google en la URL de retorno.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithGoogle(code, state).then((ok) => {
|
||||||
|
router.replace(ok ? "/" : "/?googleAuthError=1");
|
||||||
|
});
|
||||||
|
}, [searchParams, loginWithGoogle, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="text-lg font-black text-rose-600">No se pudo iniciar sesión con Google</p>
|
||||||
|
<p className="text-sm text-slate-600">{error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-bold text-slate-600">Conectando con Google…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { Clock, PackageCheck, Star } from "lucide-react";
|
||||||
|
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { formatMoney } from "@/lib/utils";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
|
|
||||||
|
type OrderItem = { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number };
|
||||||
|
type Order = { id: string; displayId: number | null; createdAt: string; status: string; total: number | null; currency: string; items: OrderItem[] };
|
||||||
|
type MyReview = { id: string; product_id: string; status: "pending" | "approved" | "rejected" };
|
||||||
|
|
||||||
|
const REVIEW_LABEL: Record<MyReview["status"], string> = {
|
||||||
|
pending: "Reseña enviada · en revisión",
|
||||||
|
approved: "Reseña publicada",
|
||||||
|
rejected: "Reseña no publicada",
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Mis pedidos": historial real del cliente (Store API) y, por cada
|
||||||
|
// producto comprado, el boton para calificarlo. Solo desde aca se puede
|
||||||
|
// reseñar: el backend exige que el pedido sea del cliente y contenga el
|
||||||
|
// producto ("Compra verificada").
|
||||||
|
export function OrdersClient() {
|
||||||
|
const { token, customer, hasHydrated } = useCustomerStore();
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [reviews, setReviews] = useState<MyReview[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
|
const [reviewing, setReviewing] = useState<{ order: Order; item: OrderItem } | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
const [ordersRes, reviewsRes] = await Promise.all([fetch("/api/customer/orders", { headers }), fetch("/api/reviews/me", { headers })]);
|
||||||
|
const ordersData = await ordersRes.json();
|
||||||
|
if (!ordersRes.ok) throw new Error(ordersData.error ?? "No pudimos cargar tus pedidos");
|
||||||
|
const reviewsData = await reviewsRes.json().catch(() => ({ reviews: [] }));
|
||||||
|
setOrders(ordersData.orders);
|
||||||
|
setReviews(reviewsRes.ok ? reviewsData.reviews : []);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError((loadError as Error).message);
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!hasHydrated) return <Shell><p className="text-slate-500">Cargando…</p></Shell>;
|
||||||
|
|
||||||
|
if (!customer || !token) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-8 text-center">
|
||||||
|
<p className="text-lg font-black text-ari-marino">Inicia sesión para ver tus pedidos</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Desde aquí puedes calificar los productos que compraste.</p>
|
||||||
|
<Button variant="primary" className="mt-5" onClick={() => setAccountOpen(true)}>Iniciar sesión</Button>
|
||||||
|
</div>
|
||||||
|
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
{error && <p className="mb-4 rounded-2xl bg-rose-50 p-4 text-sm font-bold text-rose-700">{error}</p>}
|
||||||
|
{orders === null ? (
|
||||||
|
<p className="text-slate-500">Cargando tus pedidos…</p>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
|
||||||
|
<p className="font-black text-ari-marino">Todavía no tienes pedidos</p>
|
||||||
|
<Button asChild variant="primary" className="mt-4"><Link href="/catalog">Ver catálogo</Link></Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-5">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<li key={order.id} className="rounded-2xl border border-slate-200 p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3 text-sm">
|
||||||
|
<span className="font-black text-ari-marino">Pedido #{order.displayId ?? order.id.slice(-6)}</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}
|
||||||
|
{order.total !== null && <> · {formatMoney(order.total, order.currency)}</>}
|
||||||
|
{order.status === "canceled" && <span className="ml-2 font-bold text-rose-600">Cancelado</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 grid gap-3">
|
||||||
|
{order.items.map((item) => {
|
||||||
|
const review = reviewFor(item.productId);
|
||||||
|
return (
|
||||||
|
<li key={`${order.id}-${item.productId}`} className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative size-16 shrink-0 overflow-hidden rounded-xl bg-slate-50">
|
||||||
|
{item.thumbnail && <Image src={item.thumbnail} alt="" fill sizes="64px" className="object-contain" unoptimized />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{item.handle ? (
|
||||||
|
<Link href={`/product/${item.handle}`} className="line-clamp-2 font-bold text-slate-900 hover:text-ari-coral">{item.title}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="line-clamp-2 font-bold text-slate-900">{item.title}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-500">Cantidad: {item.quantity}</span>
|
||||||
|
</div>
|
||||||
|
{review ? (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs font-bold ${review.status === "approved" ? "text-emerald-700" : review.status === "pending" ? "text-amber-700" : "text-slate-500"}`}>
|
||||||
|
{review.status === "pending" ? <Clock className="size-3.5" /> : <PackageCheck className="size-3.5" />}
|
||||||
|
{REVIEW_LABEL[review.status]}
|
||||||
|
</span>
|
||||||
|
) : order.status === "canceled" ? null : (
|
||||||
|
<Button size="sm" variant="primary" onClick={() => setReviewing({ order, item })}>
|
||||||
|
<Star className="size-4" />Calificar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<ReviewDialog
|
||||||
|
target={reviewing}
|
||||||
|
token={token}
|
||||||
|
onClose={() => setReviewing(null)}
|
||||||
|
onSubmitted={() => {
|
||||||
|
setReviewing(null);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Mi cuenta</span>
|
||||||
|
<h1 className="font-display mt-2 text-4xl font-bold tracking-tight text-ari-marino">Mis pedidos</h1>
|
||||||
|
<div className="mt-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewDialog({
|
||||||
|
target,
|
||||||
|
token,
|
||||||
|
onClose,
|
||||||
|
onSubmitted,
|
||||||
|
}: {
|
||||||
|
target: { order: Order; item: OrderItem } | null;
|
||||||
|
token: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmitted: () => void;
|
||||||
|
}) {
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [hover, setHover] = useState(0);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!target) return;
|
||||||
|
setRating(0);
|
||||||
|
setHover(0);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setError(null);
|
||||||
|
}, [target]);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!target) return;
|
||||||
|
if (rating < 1) return setError("Elige de 1 a 5 estrellas");
|
||||||
|
if (content.trim().length < 10) return setError("Cuéntanos un poco más (mínimo 10 caracteres)");
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/reviews", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ product_id: target.item.productId, order_id: target.order.id, rating, title: title.trim() || undefined, content: content.trim() }),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error ?? "No pudimos enviar tu reseña");
|
||||||
|
onSubmitted();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError((submitError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = hover || rating;
|
||||||
|
return (
|
||||||
|
<Dialog open={target !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg p-6 sm:p-8">
|
||||||
|
<DialogPrimitive.Title className="pr-10 text-2xl font-black text-ari-marino">Califica tu compra</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Description className="mt-1 line-clamp-2 text-sm text-slate-500">{target?.item.title}</DialogPrimitive.Description>
|
||||||
|
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-sm font-black">Tu calificación</legend>
|
||||||
|
{/* Patron ARIA de radiogroup (no toggles sueltos): el lector de
|
||||||
|
pantalla anuncia "3 estrellas, seleccionado, 3 de 5", un solo
|
||||||
|
tab-stop y flechas para cambiar -- con aria-pressed por
|
||||||
|
estrella solo se anunciaba la estrella exacta, no la
|
||||||
|
calificacion (reportado por frontend-critic). */}
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Tu calificación"
|
||||||
|
className="flex gap-1"
|
||||||
|
onMouseLeave={() => setHover(0)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 0;
|
||||||
|
if (!delta) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Como un radiogroup nativo: avanza desde la opcion con foco
|
||||||
|
// (si aun no hay calificacion elegida) o desde la elegida.
|
||||||
|
const buttons = [...event.currentTarget.querySelectorAll("button")];
|
||||||
|
const focused = buttons.indexOf(document.activeElement as HTMLButtonElement) + 1;
|
||||||
|
const next = Math.min(5, Math.max(1, (rating || focused || 0) + delta));
|
||||||
|
setRating(next);
|
||||||
|
setError(null);
|
||||||
|
(event.currentTarget.querySelectorAll("button")[next - 1] as HTMLButtonElement | undefined)?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={rating === value}
|
||||||
|
aria-label={`${value} ${value === 1 ? "estrella" : "estrellas"}`}
|
||||||
|
tabIndex={rating ? (rating === value ? 0 : -1) : value === 1 ? 0 : -1}
|
||||||
|
onMouseEnter={() => setHover(value)}
|
||||||
|
onClick={() => { setRating(value); setError(null); }}
|
||||||
|
className="rounded-md p-0.5 transition hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ari-marino"
|
||||||
|
>
|
||||||
|
<Star className={`size-9 ${value <= shown ? "fill-amber-500 text-amber-600" : "text-slate-400"}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="grid gap-1.5 text-sm font-black">
|
||||||
|
Título (opcional)
|
||||||
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={80} placeholder="Ej: ¡A mi hijo le encantó!" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5 text-sm font-black">
|
||||||
|
Tu reseña
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
rows={5}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="¿Qué te pareció el producto? ¿Llegó bien?"
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white p-4 text-sm font-normal text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500">Tu reseña se publica después de que la revisemos. Mostramos tu nombre y la inicial de tu apellido.</p>
|
||||||
|
{error && <p className="rounded-xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}
|
||||||
|
<Button type="submit" variant="primary" size="lg" disabled={sending}>{sending ? "Enviando…" : "Enviar reseña"}</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
||||||
|
import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy";
|
||||||
|
|
||||||
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
||||||
@@ -23,12 +24,19 @@ type CatalogClientProps = {
|
|||||||
initialCategory?: string;
|
initialCategory?: string;
|
||||||
initialCollection?: string;
|
initialCollection?: string;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
|
initialTaxonomy?: TaxonomyFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sin guiones ni espacios: "spiderman" encuentra "Spider-Man".
|
||||||
|
function normalizeSearch(value: string): string {
|
||||||
|
return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function matchesQuery(product: Product, query: string): boolean {
|
function matchesQuery(product: Product, query: string): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
|
||||||
return search.includes(query.toLowerCase());
|
const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" "));
|
||||||
|
return search.includes(normalizeSearch(query));
|
||||||
}
|
}
|
||||||
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
||||||
return allowedCategories.includes(product.category);
|
return allowedCategories.includes(product.category);
|
||||||
@@ -69,8 +77,18 @@ export function CatalogClient({
|
|||||||
initialCategory = "Todos",
|
initialCategory = "Todos",
|
||||||
initialCollection = "",
|
initialCollection = "",
|
||||||
initialQuery = "",
|
initialQuery = "",
|
||||||
|
initialTaxonomy = {},
|
||||||
}: CatalogClientProps) {
|
}: CatalogClientProps) {
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
// Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side
|
||||||
|
// sobre los tags -- Meilisearch no tiene estos campos como facetas.
|
||||||
|
const [taxonomy, setTaxonomy] = useState<TaxonomyFilter>(initialTaxonomy);
|
||||||
|
const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje);
|
||||||
|
// El titulo solo refleja la franquicia/saga/personaje que viene en la URL
|
||||||
|
// (menu "Personajes"), igual que la barra de navegacion: si reaccionara
|
||||||
|
// al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con
|
||||||
|
// "Todo" marcado activo arriba (reportado por frontend-critic).
|
||||||
|
const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || "";
|
||||||
const [category, setCategory] = useState(initialCategory);
|
const [category, setCategory] = useState(initialCategory);
|
||||||
const [collection, setCollection] = useState(initialCollection);
|
const [collection, setCollection] = useState(initialCollection);
|
||||||
const [brand, setBrand] = useState("Todas");
|
const [brand, setBrand] = useState("Todas");
|
||||||
@@ -96,13 +114,30 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
setMaxPrice(maxCatalogPrice);
|
setMaxPrice(maxCatalogPrice);
|
||||||
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
|
setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje });
|
||||||
|
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]);
|
||||||
|
|
||||||
|
// Valores de franquicia/personaje presentes en el catalogo. Los
|
||||||
|
// personajes se acotan a la franquicia elegida (si hay una), para no
|
||||||
|
// ofrecer "Spider-Man" mientras se mira Disney.
|
||||||
|
const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]);
|
||||||
|
const characters = useMemo(
|
||||||
|
() => [
|
||||||
|
"Todos",
|
||||||
|
...new Set(
|
||||||
|
products
|
||||||
|
.filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }))
|
||||||
|
.flatMap((product) => taxonomyValues(product, "personaje")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[products, taxonomy.franquicia, taxonomy.saga],
|
||||||
|
);
|
||||||
|
|
||||||
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
|
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
|
||||||
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
// expande a la lista de nombres reales de sus hijos -- ningun producto
|
||||||
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
|
||||||
// trata como el nombre de categoria exacto de siempre.
|
// trata como el nombre de categoria exacto de siempre.
|
||||||
const allowedCategories = categoryGroups[category] ?? [category];
|
const allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]);
|
||||||
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
||||||
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
||||||
const promoRoots = useMemo(() => {
|
const promoRoots = useMemo(() => {
|
||||||
@@ -120,7 +155,8 @@ export function CatalogClient({
|
|||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
)
|
)
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
sort === "name"
|
sort === "name"
|
||||||
@@ -129,7 +165,7 @@ export function CatalogClient({
|
|||||||
? Number(b.isNew) - Number(a.isNew)
|
? Number(b.isNew) - Number(a.isNew)
|
||||||
: Number(b.featured) - Number(a.featured),
|
: Number(b.featured) - Number(a.featured),
|
||||||
),
|
),
|
||||||
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
|
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
||||||
@@ -161,7 +197,7 @@ export function CatalogClient({
|
|||||||
// la busqueda en vivo todavia no respondio.
|
// la busqueda en vivo todavia no respondio.
|
||||||
const fallbackCategoryCounts = useMemo(() => {
|
const fallbackCategoryCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todos: base.length };
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
for (const name of categories) {
|
for (const name of categories) {
|
||||||
@@ -170,7 +206,7 @@ export function CatalogClient({
|
|||||||
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackBrandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -179,7 +215,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesAge(product, age) &&
|
matchesAge(product, age) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of brands) {
|
for (const value of brands) {
|
||||||
@@ -187,7 +224,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.brand === value).length;
|
counts[value] = base.filter((product) => product.brand === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
const fallbackAgeCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
@@ -196,7 +233,8 @@ export function CatalogClient({
|
|||||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
matchesCollection(product, collection) &&
|
matchesCollection(product, collection) &&
|
||||||
matchesBrand(product, brand) &&
|
matchesBrand(product, brand) &&
|
||||||
matchesPrice(product, maxPrice),
|
matchesPrice(product, maxPrice) &&
|
||||||
|
matchesTaxonomy(product, taxonomy),
|
||||||
);
|
);
|
||||||
const counts: Record<string, number> = { Todas: base.length };
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
for (const value of ages) {
|
for (const value of ages) {
|
||||||
@@ -204,7 +242,7 @@ export function CatalogClient({
|
|||||||
counts[value] = base.filter((product) => product.ageRange === value).length;
|
counts[value] = base.filter((product) => product.ageRange === value).length;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]);
|
||||||
|
|
||||||
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
||||||
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||||
@@ -296,18 +334,33 @@ export function CatalogClient({
|
|||||||
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||||
|
|
||||||
const categoryCounts = useMemo(
|
const categoryCounts = useMemo(
|
||||||
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
() => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||||
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const brandCounts = useMemo(
|
const brandCounts = useMemo(
|
||||||
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
() => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||||
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
const ageCounts = useMemo(
|
const ageCounts = useMemo(
|
||||||
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
() => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||||
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Conteos de franquicia/personaje: siempre client-side (no son facetas
|
||||||
|
// de Meilisearch), excluyendo su propia dimension como el resto.
|
||||||
|
const franchiseCounts = useMemo(() => {
|
||||||
|
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje }));
|
||||||
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
|
for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length;
|
||||||
|
return counts;
|
||||||
|
}, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]);
|
||||||
|
const characterCounts = useMemo(() => {
|
||||||
|
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }));
|
||||||
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
|
for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length;
|
||||||
|
return counts;
|
||||||
|
}, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
@@ -316,6 +369,7 @@ export function CatalogClient({
|
|||||||
setAge("Todas");
|
setAge("Todas");
|
||||||
setMaxPrice(maxCatalogPrice);
|
setMaxPrice(maxCatalogPrice);
|
||||||
setSort("featured");
|
setSort("featured");
|
||||||
|
setTaxonomy({});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -343,16 +397,20 @@ export function CatalogClient({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
||||||
Catálogo completo
|
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1>
|
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
|
||||||
<p className="mt-3 text-slate-500">
|
<p className="mt-3 text-slate-500">
|
||||||
Filtra por categoría, marca o edad sin recargar la página.
|
Filtra por categoría, marca o edad sin recargar la página.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
|
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
|
||||||
<aside className="h-fit space-y-6 rounded-lg border border-slate-100 bg-white p-6 shadow-lg shadow-ari-marino/5 lg:sticky lg:top-44">
|
{/* overflow-y-auto + max-h propio en lg: sticky no le da scroll
|
||||||
|
propio al contenido, y con 30+ categorias mas marca/edad/precio
|
||||||
|
el panel se pasa del viewport sin forma de llegar al resto salvo
|
||||||
|
bajando el zoom del navegador (reportado en vivo). */}
|
||||||
|
<aside className="h-fit space-y-6 overflow-y-auto rounded-lg border border-slate-100 bg-white p-6 shadow-lg shadow-ari-marino/5 lg:sticky lg:top-44 lg:max-h-[calc(100vh-13rem)]">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<strong className="flex items-center gap-2 text-lg font-black">
|
<strong className="flex items-center gap-2 text-lg font-black">
|
||||||
<SlidersHorizontal className="size-5 text-ari-coral-claro" />
|
<SlidersHorizontal className="size-5 text-ari-coral-claro" />
|
||||||
@@ -377,6 +435,33 @@ export function CatalogClient({
|
|||||||
onChange={setCategory}
|
onChange={setCategory}
|
||||||
counts={categoryCounts}
|
counts={categoryCounts}
|
||||||
/>
|
/>
|
||||||
|
{franchises.length > 1 && (
|
||||||
|
<Filter
|
||||||
|
title="Franquicia"
|
||||||
|
values={franchises}
|
||||||
|
selected={taxonomy.franquicia || "Todas"}
|
||||||
|
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
|
||||||
|
counts={franchiseCounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{taxonomy.saga && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm font-black">Saga</p>
|
||||||
|
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
|
||||||
|
{taxonomy.saga}
|
||||||
|
<X className="size-3" aria-label="Quitar saga" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{characters.length > 1 && (
|
||||||
|
<Filter
|
||||||
|
title="Personaje"
|
||||||
|
values={characters}
|
||||||
|
selected={taxonomy.personaje || "Todos"}
|
||||||
|
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
|
||||||
|
counts={characterCounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
||||||
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,24 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, MapPin, MessageCircle, Truck } from "lucide-react";
|
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { useLocationStore } from "@/store/location-store";
|
||||||
|
|
||||||
// ADR 0009, siguiente corte tras el carrito real: direccion + seleccion de
|
const SELECT_CLASS =
|
||||||
// envio contra el Store API real (mismos endpoints ya verificados en vivo
|
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
|
||||||
// para las shipping options). El pago (PayU custom) todavia no existe --
|
|
||||||
// el ultimo paso sigue siendo WhatsApp, ahora con el pedido completo
|
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
|
||||||
// (direccion, envio, total real) en vez de solo la lista de productos.
|
// El pago es ePayco/PSE (modulo custom del backend, ver
|
||||||
|
// workloads/commerce-backend/src/modules/epayco-pse) -- redirige al banco
|
||||||
|
// del comprador, la confirmacion real llega despues via webhook async
|
||||||
|
// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la
|
||||||
|
// iniciacion del pago falla, ya no como camino principal.
|
||||||
|
|
||||||
interface ShippingOption {
|
interface ShippingOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,7 +27,12 @@ interface ShippingOption {
|
|||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = "address" | "shipping" | "review";
|
interface PseBank {
|
||||||
|
pseCode: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "address" | "shipping" | "payment";
|
||||||
|
|
||||||
async function postJson(url: string, body: unknown) {
|
async function postJson(url: string, body: unknown) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -35,24 +46,70 @@ async function postJson(url: string, body: unknown) {
|
|||||||
export function CheckoutClient() {
|
export function CheckoutClient() {
|
||||||
const { cartId, items, hasHydrated } = useCartStore();
|
const { cartId, items, hasHydrated } = useCartStore();
|
||||||
const [step, setStep] = useState<Step>("address");
|
const [step, setStep] = useState<Step>("address");
|
||||||
const [form, setForm] = useState({ firstName: "", lastName: "", address1: "", city: "", province: "", phone: "", email: "" });
|
const [form, setForm] = useState({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
address1: "",
|
||||||
|
// Se guarda el id DIVIPOLA seleccionado, no el texto libre -- ver
|
||||||
|
// lib/data/colombia.ts. El nombre real se deriva de este id para
|
||||||
|
// mandarlo a Medusa (que solo espera texto en shipping_address).
|
||||||
|
departmentId: null as number | null,
|
||||||
|
cityId: null as number | null,
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
});
|
||||||
|
const availableCities = form.departmentId ? ciudadesPorDepartamento(form.departmentId) : [];
|
||||||
|
const selectedDepartamento = departamentos.find((departamento) => departamento.id === form.departmentId);
|
||||||
|
const selectedCiudad = availableCities.find((ciudad) => ciudad.id === form.cityId);
|
||||||
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([]);
|
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([]);
|
||||||
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
|
const [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
|
||||||
const [shippingTotal, setShippingTotal] = useState(0);
|
const [shippingTotal, setShippingTotal] = useState(0);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [banks, setBanks] = useState<PseBank[]>([]);
|
||||||
|
const [bankCode, setBankCode] = useState("");
|
||||||
|
const [userType, setUserType] = useState<"0" | "1">("0");
|
||||||
|
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
||||||
|
const [docNumber, setDocNumber] = useState("");
|
||||||
|
|
||||||
|
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
|
||||||
|
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
|
||||||
|
const savedDepartmentId = useLocationStore((state) => state.departmentId);
|
||||||
|
const savedCityId = useLocationStore((state) => state.cityId);
|
||||||
|
const locationHydrated = useLocationStore((state) => state.hasHydrated);
|
||||||
|
const saveLocation = useLocationStore((state) => state.setLocation);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
|
||||||
|
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
|
||||||
|
}, [locationHydrated, savedDepartmentId, savedCityId]);
|
||||||
|
|
||||||
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
||||||
const currency = items[0]?.product.currency;
|
const currency = items[0]?.product.currency;
|
||||||
|
|
||||||
async function submitAddress(event: FormEvent) {
|
async function submitAddress(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!cartId) return;
|
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
||||||
|
saveLocation(selectedDepartamento.id, selectedCiudad.id);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const cart = await postJson("/api/cart/address", { cartId, ...form });
|
const cart = await postJson("/api/cart/address", {
|
||||||
|
cartId,
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
address1: form.address1,
|
||||||
|
city: selectedCiudad.name,
|
||||||
|
province: selectedDepartamento.name,
|
||||||
|
// ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los
|
||||||
|
// necesita para cotizar sin ambiguedad, el nombre solo no
|
||||||
|
// alcanza (hay municipios con el mismo nombre en distintos
|
||||||
|
// departamentos).
|
||||||
|
cityId: selectedCiudad.id,
|
||||||
|
departmentId: selectedDepartamento.id,
|
||||||
|
phone: form.phone,
|
||||||
|
email: form.email,
|
||||||
|
});
|
||||||
if (cart.error) throw new Error(cart.error);
|
if (cart.error) throw new Error(cart.error);
|
||||||
const response = await fetch(`/api/cart/shipping-options?cartId=${cartId}`);
|
const response = await fetch(`/api/cart/shipping-options?cartId=${cartId}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -76,7 +133,12 @@ export function CheckoutClient() {
|
|||||||
if (cart.error) throw new Error(cart.error);
|
if (cart.error) throw new Error(cart.error);
|
||||||
setShippingTotal(cart.shippingTotal ?? 0);
|
setShippingTotal(cart.shippingTotal ?? 0);
|
||||||
setTotal(cart.total ?? itemsSubtotal);
|
setTotal(cart.total ?? itemsSubtotal);
|
||||||
setStep("review");
|
const banksResponse = await fetch("/api/cart/payment/banks");
|
||||||
|
const banksData = await banksResponse.json();
|
||||||
|
const bankList: PseBank[] = banksData.banks ?? [];
|
||||||
|
setBanks(bankList);
|
||||||
|
setBankCode(bankList[0]?.pseCode ?? "");
|
||||||
|
setStep("payment");
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError((submitError as Error).message);
|
setError((submitError as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,9 +146,40 @@ export function CheckoutClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitPayment(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!cartId || !bankCode || !docNumber || !selectedCiudad || !selectedDepartamento) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const session = await postJson("/api/cart/payment/session", {
|
||||||
|
cartId,
|
||||||
|
bankCode,
|
||||||
|
userType,
|
||||||
|
docType,
|
||||||
|
docNumber,
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
email: form.email,
|
||||||
|
phone: form.phone,
|
||||||
|
city: selectedCiudad.name,
|
||||||
|
});
|
||||||
|
if (session.error) throw new Error(session.error);
|
||||||
|
const completion = await postJson("/api/cart/complete", { cartId });
|
||||||
|
if (completion.error) throw new Error(completion.error);
|
||||||
|
// Navegacion completa, no iframe -- asi lo exige ePayco para el
|
||||||
|
// flujo de PSE. La confirmacion real llega despues via webhook,
|
||||||
|
// esta pagina nunca vuelve a cargar sola.
|
||||||
|
window.location.href = session.bankUrl;
|
||||||
|
} catch (submitError) {
|
||||||
|
setError((submitError as Error).message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function continueOnWhatsApp() {
|
function continueOnWhatsApp() {
|
||||||
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
|
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
|
||||||
const message = `Hola Ari Shopping 👋\nQuiero confirmar mi pedido:\n\n${lines}\n\nSubtotal: ${formatMoney(itemsSubtotal, currency)}\nEnvío: ${formatMoney(shippingTotal, currency)}\nTotal: ${formatMoney(total, currency)}\n\nNombre: ${form.firstName} ${form.lastName}\nDirección: ${form.address1}, ${form.city}${form.province ? `, ${form.province}` : ""}\nTeléfono: ${form.phone}`;
|
const message = `Hola Ari Shopping 👋\nQuiero confirmar mi pedido:\n\n${lines}\n\nSubtotal: ${formatMoney(itemsSubtotal, currency)}\nEnvío: ${formatMoney(shippingTotal, currency)}\nTotal: ${formatMoney(total, currency)}\n\nNombre: ${form.firstName} ${form.lastName}\nDirección: ${form.address1}, ${selectedCiudad?.name ?? ""}${selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}\nTeléfono: ${form.phone}`;
|
||||||
void navigator.clipboard?.writeText(message);
|
void navigator.clipboard?.writeText(message);
|
||||||
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
@@ -116,8 +209,25 @@ export function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
<Input placeholder="Dirección" required value={form.address1} onChange={(event) => setForm({ ...form, address1: event.target.value })} />
|
<Input placeholder="Dirección" required value={form.address1} onChange={(event) => setForm({ ...form, address1: event.target.value })} />
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Input placeholder="Ciudad" required value={form.city} onChange={(event) => setForm({ ...form, city: event.target.value })} />
|
<select
|
||||||
<Input placeholder="Departamento" value={form.province} onChange={(event) => setForm({ ...form, province: event.target.value })} />
|
required
|
||||||
|
value={form.departmentId ?? ""}
|
||||||
|
onChange={(event) => setForm({ ...form, departmentId: Number(event.target.value) || null, cityId: null })}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Departamento</option>
|
||||||
|
{departamentos.map((departamento) => <option key={departamento.id} value={departamento.id}>{departamento.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
disabled={!form.departmentId}
|
||||||
|
value={form.cityId ?? ""}
|
||||||
|
onChange={(event) => setForm({ ...form, cityId: Number(event.target.value) || null })}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Ciudad</option>
|
||||||
|
{availableCities.map((ciudad) => <option key={ciudad.id} value={ciudad.id}>{ciudad.name}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Input placeholder="Teléfono" required value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
|
<Input placeholder="Teléfono" required value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
|
||||||
@@ -130,7 +240,7 @@ export function CheckoutClient() {
|
|||||||
|
|
||||||
{step === "shipping" && (
|
{step === "shipping" && (
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<p className="flex items-center gap-2 text-sm font-bold text-slate-500"><MapPin className="size-4" />{form.address1}, {form.city}{form.province ? `, ${form.province}` : ""}</p>
|
<p className="flex items-center gap-2 text-sm font-bold text-slate-500"><MapPin className="size-4" />{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{shippingOptions.map((option) => (
|
{shippingOptions.map((option) => (
|
||||||
<label key={option.id} className={`flex cursor-pointer items-center justify-between rounded-2xl border-2 p-4 transition ${selectedOptionId === option.id ? "border-ari-marino bg-ari-marino/5" : "border-slate-200"}`}>
|
<label key={option.id} className={`flex cursor-pointer items-center justify-between rounded-2xl border-2 p-4 transition ${selectedOptionId === option.id ? "border-ari-marino bg-ari-marino/5" : "border-slate-200"}`}>
|
||||||
@@ -148,17 +258,55 @@ export function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "review" && (
|
{step === "payment" && (
|
||||||
<div className="mt-8 space-y-4">
|
<form onSubmit={submitPayment} className="mt-8 space-y-4">
|
||||||
<div className="rounded-2xl border border-slate-200 p-5">
|
<div className="rounded-2xl border border-slate-200 p-5">
|
||||||
<p className="text-xs font-black uppercase tracking-wide text-slate-400">Entrega</p>
|
<p className="text-xs font-black uppercase tracking-wide text-slate-400">Entrega</p>
|
||||||
<p className="mt-1 font-bold">{form.firstName} {form.lastName}</p>
|
<p className="mt-1 font-bold">{form.firstName} {form.lastName}</p>
|
||||||
<p className="text-sm text-slate-500">{form.address1}, {form.city}{form.province ? `, ${form.province}` : ""}</p>
|
<p className="text-sm text-slate-500">{form.address1}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
||||||
<p className="text-sm text-slate-500">{form.phone}</p>
|
<p className="text-sm text-slate-500">{form.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">El pago en línea todavía no está disponible — confirmamos tu pedido por WhatsApp con estos datos.</p>
|
|
||||||
<Button variant="primary" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
|
<p className="flex items-center gap-2 text-sm font-bold text-slate-700"><Landmark className="size-4" />Paga con PSE (transferencia desde tu banco)</p>
|
||||||
|
|
||||||
|
{banks.length > 0 ? (
|
||||||
|
<select required value={bankCode} onChange={(event) => setBankCode(event.target.value)} className={SELECT_CLASS}>
|
||||||
|
{banks.map((bank) => <option key={bank.pseCode} value={bank.pseCode}>{bank.description}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">No pudimos cargar la lista de bancos — probá de nuevo en un momento, o confirmá tu pedido por WhatsApp abajo.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 text-sm font-bold">
|
||||||
|
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "0"} onChange={() => setUserType("0")} />Persona natural</label>
|
||||||
|
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "1"} onChange={() => setUserType("1")} />Persona jurídica</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={docType}
|
||||||
|
onChange={(event) => setDocType(event.target.value as typeof docType)}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
|
<option value="CC">Cédula de ciudadanía</option>
|
||||||
|
<option value="CE">Cédula de extranjería</option>
|
||||||
|
<option value="TI">Tarjeta de identidad</option>
|
||||||
|
<option value="PP">Pasaporte</option>
|
||||||
|
<option value="NIT">NIT</option>
|
||||||
|
</select>
|
||||||
|
<Input placeholder="Número de documento" required value={docNumber} onChange={(event) => setDocNumber(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" size="lg" className="w-full" disabled={loading || banks.length === 0}>{loading ? "Redirigiendo a tu banco…" : "Pagar con PSE"}</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||||
|
<span className="h-px flex-1 bg-slate-200" />¿Prefieres otra vía?<span className="h-px flex-1 bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="lg" className="w-full" onClick={continueOnWhatsApp}><MessageCircle className="size-5" />Confirmar por WhatsApp</Button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,8 +323,8 @@ export function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-5 space-y-2 border-t border-slate-100 pt-4 text-sm">
|
<div className="mt-5 space-y-2 border-t border-slate-100 pt-4 text-sm">
|
||||||
<div className="flex justify-between"><span className="text-slate-500">Subtotal</span><span className="font-bold">{formatMoney(itemsSubtotal, currency)}</span></div>
|
<div className="flex justify-between"><span className="text-slate-500">Subtotal</span><span className="font-bold">{formatMoney(itemsSubtotal, currency)}</span></div>
|
||||||
{step === "review" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
|
{step === "payment" && <div className="flex justify-between"><span className="text-slate-500">Envío</span><span className="font-bold">{formatMoney(shippingTotal, currency)}</span></div>}
|
||||||
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "review" ? total : itemsSubtotal, currency)}</span></div>
|
<div className="flex justify-between text-base"><span className="font-black">Total</span><span className="font-black">{formatMoney(step === "payment" ? total : itemsSubtotal, currency)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
// ePayco redirige aca despues de que el comprador paga en su banco --
|
||||||
|
// esto NO confirma el pago. Los parametros de esta URL son solo
|
||||||
|
// informativos; el estado real y definitivo llega por el webhook async
|
||||||
|
// de ePayco al backend (getWebhookActionAndData en el modulo
|
||||||
|
// epayco-pse), nunca por esta pantalla. Por eso el mensaje nunca afirma
|
||||||
|
// exito, solo que estamos esperando la confirmacion.
|
||||||
|
export function PseReturnClient() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const referenceCode = searchParams.get("referenceCode") ?? searchParams.get("reference_sale");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg px-4 py-24 text-center">
|
||||||
|
<Clock className="mx-auto size-12 text-ari-marino" />
|
||||||
|
<h1 className="mt-6 text-2xl font-black">Estamos confirmando tu pago</h1>
|
||||||
|
<p className="mt-3 text-slate-500">
|
||||||
|
Tu banco ya procesó la transferencia. ePayco nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
|
||||||
|
{referenceCode && (
|
||||||
|
<>
|
||||||
|
{" "}Tu número de referencia es <strong>{referenceCode}</strong>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-slate-400">
|
||||||
|
Si después de un rato no ves la confirmación, escribinos por WhatsApp con este número de referencia.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="primary" className="mt-8">
|
||||||
|
<Link href="/catalog">Volver al catálogo</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,33 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
// ADR 0008 sub-fase 4: hero azul con dots de paginacion (carrusel), en vez
|
// ADR 0008 sub-fase 4: hero azul con dots de paginacion (carrusel), en vez
|
||||||
// del bento estatico de 3 tarjetas en fucsia/rojo/violeta del esquema
|
// del bento estatico de 3 tarjetas en fucsia/rojo/violeta del esquema
|
||||||
// arcoiris original. Un slide a la vez, fondo azul de marca en los tres,
|
// arcoiris original. Un slide a la vez, fondo azul de marca en los tres,
|
||||||
// para leerse como una sola pieza de marca en vez de 3 bloques de colores
|
// para leerse como una sola pieza de marca en vez de 3 bloques de colores
|
||||||
// distintos.
|
// distintos.
|
||||||
const slides = [
|
//
|
||||||
|
// ADR 0010: los slides ya no son un array hardcodeado -- se derivan de
|
||||||
|
// productos activos reales del catalogo (pasados por prop desde
|
||||||
|
// app/page.tsx, que ya trae `products` del backend). Fallback estatico
|
||||||
|
// solo para el caso borde de una tienda sin ningun producto activo.
|
||||||
|
type Slide = {
|
||||||
|
id: string;
|
||||||
|
badge: string;
|
||||||
|
icon: typeof Sparkles;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
image: string;
|
||||||
|
imageAlt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_SLIDES: Slide[] = [
|
||||||
{
|
{
|
||||||
id: "celebracion",
|
id: "celebracion",
|
||||||
badge: "Celebración ARI",
|
badge: "Celebración ARI",
|
||||||
@@ -48,17 +66,46 @@ const slides = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MAX_SLIDES = 5;
|
||||||
const AUTOPLAY_MS = 6000;
|
const AUTOPLAY_MS = 6000;
|
||||||
|
|
||||||
export function HeroCarousel() {
|
function slidesFromProducts(products: Product[]): Slide[] {
|
||||||
|
return products.slice(0, MAX_SLIDES).map((product) => {
|
||||||
|
const hasDiscount = product.compareAtPrice !== null && product.compareAtPrice > (product.price ?? 0);
|
||||||
|
const pctOff = hasDiscount && product.price !== null
|
||||||
|
? Math.round(100 - (product.price / (product.compareAtPrice as number)) * 100)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
badge: hasDiscount ? `${pctOff}% OFF` : product.collection ? `Colección ${product.collection}` : "Destacado",
|
||||||
|
icon: hasDiscount ? BadgePercent : Sparkles,
|
||||||
|
title: product.name,
|
||||||
|
subtitle: product.priceLabel,
|
||||||
|
description: product.shortDescription || "Descúbrelo en la tienda ARI Shopping.",
|
||||||
|
href: `/product/${product.slug}`,
|
||||||
|
image: product.image,
|
||||||
|
imageAlt: product.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroCarousel({ products }: { products: Product[] }) {
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const slides = useMemo(() => {
|
||||||
|
const fromProducts = slidesFromProducts(products);
|
||||||
|
return fromProducts.length > 0 ? fromProducts : FALLBACK_SLIDES;
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reduceMotion) return;
|
if (reduceMotion) return;
|
||||||
const timer = setInterval(() => setIndex((current) => (current + 1) % slides.length), AUTOPLAY_MS);
|
const timer = setInterval(() => setIndex((current) => (current + 1) % slides.length), AUTOPLAY_MS);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [reduceMotion]);
|
}, [reduceMotion, slides.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (index >= slides.length) setIndex(0);
|
||||||
|
}, [slides.length, index]);
|
||||||
|
|
||||||
const slide = slides[index];
|
const slide = slides[index];
|
||||||
if (!slide) return null;
|
if (!slide) return null;
|
||||||
|
|||||||
@@ -1,10 +1,51 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Facebook, Instagram, MessageCircle } from "lucide-react";
|
import { Facebook, Instagram, MessageCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// APP_VERSION la actualiza el propio CI (.gitea/workflows/build.yaml) al
|
||||||
|
// mismo tiempo que el tag de la imagen -- antes decia "v4.1" fijo,
|
||||||
|
// inutil para distinguir a simple vista si Staging y Produccion estan
|
||||||
|
// en la misma version o no.
|
||||||
|
const appVersion = process.env.APP_VERSION || "dev";
|
||||||
|
|
||||||
|
// Las 4 paginas de "Ayuda" ya tienen contenido real (app/envios,
|
||||||
|
// app/cambios, app/preguntas-frecuentes, app/contacto).
|
||||||
|
const HELP_LINKS = [
|
||||||
|
{ label: "Preguntas frecuentes", href: "/preguntas-frecuentes" },
|
||||||
|
{ label: "Envíos", href: "/envios" },
|
||||||
|
{ label: "Cambios", href: "/cambios" },
|
||||||
|
{ label: "Contacto", href: "/contacto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cuentas reales confirmadas por el usuario (Instagram verificado en
|
||||||
|
// vivo antes de linkearlo -- la primera cuenta sugerida, @arishopping,
|
||||||
|
// no era la real). El link de WhatsApp usa el mismo numero real ya
|
||||||
|
// confirmado en /contacto y en el fallback del checkout.
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ label: "Instagram", href: "https://www.instagram.com/ari_shopping1/", Icon: Instagram },
|
||||||
|
{ label: "Facebook", href: "https://www.facebook.com/share/1Dkmvko6HF/", Icon: Facebook },
|
||||||
|
{ label: "WhatsApp", href: "https://wa.me/573016138155", Icon: MessageCircle },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHOP_LINKS = [
|
||||||
|
{ label: "Novedades", href: "/catalog" },
|
||||||
|
{ label: "Juguetes", href: "/catalog" },
|
||||||
|
{ label: "Mochilas", href: "/catalog" },
|
||||||
|
{ label: "Regalos", href: "/catalog" },
|
||||||
|
];
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
return <footer className="bg-ari-marino text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex flex-col items-start gap-3"><img src="/images/brand/logo-ari.svg" alt="ARI shopping" className="h-11 w-auto" width={200} height={64} />{/* eslint-disable-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}<p className="text-xs text-white/60">Tesoros para cada aventura.</p></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-ari-coral px-4 text-sm font-black text-white">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.1 · GitOps</div></footer>;
|
return <footer className="bg-ari-marino text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex flex-col items-start gap-3"><img src="/images/brand/logo-ari.svg" alt="ARI shopping" className="h-11 w-auto" width={200} height={64} />{/* eslint-disable-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}<p className="text-xs text-white/60">Tesoros para cada aventura.</p></div><div className="mt-5 flex gap-3">{SOCIAL_LINKS.map(({ label, href, Icon }) => <a key={label} href={href} target="_blank" rel="noopener noreferrer" aria-label={label} className="text-white/80 transition hover:text-ari-coral-claro"><Icon /></a>)}</div></div><FooterColumn title="Compra" links={SHOP_LINKS} /><FooterColumn title="Ayuda" links={HELP_LINKS} />{/* min-w-0: sin esto, un grid item de texto toma como minimo su ancho
|
||||||
|
max-content (una sola linea, "Recibe lanzamientos y promociones
|
||||||
|
especiales." sin wrappear, ~335px) en vez de dejarse achicar y
|
||||||
|
wrappear dentro del track del grid -- confirmado en vivo 2026-09-20
|
||||||
|
contra un viewport real de 320px: la columna entera del footer (y
|
||||||
|
con ella, la pagina completa) quedaba forzada a 335px de ancho por
|
||||||
|
este unico parrafo. Mismo bug de fondo que el ya documentado en
|
||||||
|
product-gallery.tsx/app/product/[slug]/page.tsx, en un grid en vez
|
||||||
|
de un overflow-x-auto. */}
|
||||||
|
<div className="min-w-0"><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-ari-coral px-4 text-sm font-black text-white">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless {appVersion} · GitOps</div></footer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FooterColumn({ title, links }: { title: string; links: string[] }) {
|
function FooterColumn({ title, links }: { title: string; links: { label: string; href: string }[] }) {
|
||||||
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link} href="/catalog" className="hover:text-ari-coral-claro">{link}</Link>)}</div></div>;
|
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link.label} href={link.href} className="hover:text-ari-coral-claro">{link.label}</Link>)}</div></div>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
|
import { useLocationStore } from "@/store/location-store";
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
|
||||||
|
|
||||||
|
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
|
||||||
|
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
|
||||||
|
export function LocationPicker({ firstName }: { firstName: string | null }) {
|
||||||
|
const departmentId = useLocationStore((state) => state.departmentId);
|
||||||
|
const cityId = useLocationStore((state) => state.cityId);
|
||||||
|
const hydrated = useLocationStore((state) => state.hasHydrated);
|
||||||
|
const setLocation = useLocationStore((state) => state.setLocation);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
|
||||||
|
const [draftCity, setDraftCity] = useState<number | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraftDepartment(departmentId);
|
||||||
|
setDraftCity(cityId);
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, departmentId, cityId]);
|
||||||
|
|
||||||
|
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<MapPin className="size-5 shrink-0 text-ari-coral" />
|
||||||
|
<span className="leading-tight">
|
||||||
|
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
|
||||||
|
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
|
||||||
|
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<select
|
||||||
|
aria-label="Departamento"
|
||||||
|
className={selectClass}
|
||||||
|
value={draftDepartment ?? ""}
|
||||||
|
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
|
||||||
|
>
|
||||||
|
<option value="">Departamento</option>
|
||||||
|
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Ciudad"
|
||||||
|
className={selectClass}
|
||||||
|
value={draftCity ?? ""}
|
||||||
|
disabled={!draftDepartment}
|
||||||
|
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
|
||||||
|
>
|
||||||
|
<option value="">Ciudad o municipio</option>
|
||||||
|
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!draftDepartment || !draftCity}
|
||||||
|
onClick={() => {
|
||||||
|
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Guardar ciudad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { ChevronDown, Heart, LayoutGrid, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
|
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
|
import { LocationPicker } from "@/components/layout/location-picker";
|
||||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import type { Category, Product } from "@/lib/headless/types";
|
import type { Category, Product } from "@/lib/headless/types";
|
||||||
|
import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
import { useWishlistStore } from "@/store/wishlist-store";
|
import { useWishlistStore } from "@/store/wishlist-store";
|
||||||
|
|
||||||
type CategoryNode = Category & { children: Category[] };
|
type CategoryNode = Category & { children: Category[] };
|
||||||
@@ -34,35 +37,89 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
const openWishlist = useWishlistStore((state) => state.openWishlist);
|
const openWishlist = useWishlistStore((state) => state.openWishlist);
|
||||||
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
|
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
|
||||||
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
|
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
|
||||||
|
const customer = useCustomerStore((state) => state.customer);
|
||||||
|
const customerHydrated = useCustomerStore((state) => state.hasHydrated);
|
||||||
|
const isLoggedIn = customerHydrated && customer !== null;
|
||||||
|
|
||||||
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
const tree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||||
// Derivado de los productos reales (no de un endpoint de colecciones):
|
// Derivado de los productos reales (no de un endpoint de colecciones):
|
||||||
// solo aparecen colecciones que de verdad tienen productos hoy.
|
// solo aparecen colecciones que de verdad tienen productos hoy.
|
||||||
|
// Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el
|
||||||
|
// provider no trae tags (mock/shopify), "Personajes" cae a la lista plana
|
||||||
|
// de colecciones de siempre.
|
||||||
|
const franchises = useMemo(() => buildFranchiseTree(products), [products]);
|
||||||
const collections = useMemo(
|
const collections = useMemo(
|
||||||
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
||||||
[products],
|
[products],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
|
||||||
|
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
|
||||||
|
// se expande recien por debajo de 40px) porque achicar un header sticky
|
||||||
|
// cambia la altura del documento -- con un solo umbral el header
|
||||||
|
// parpadea entre los dos estados justo en el borde.
|
||||||
|
const [compact, setCompact] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const y = window.scrollY;
|
||||||
|
setCompact((current) => (current ? y > 40 : y > 160));
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
<header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
||||||
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm">
|
<div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
|
||||||
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
|
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-visible bg-white">
|
<div className="relative overflow-visible bg-white">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
|
||||||
<div className="mx-auto grid max-w-7xl grid-cols-[auto_1fr_auto] items-center gap-4 px-4 py-4 lg:gap-8">
|
{/* ADR 0010: en mobile/tablet el buscador se movio a una segunda
|
||||||
|
fila a todo el ancho, debajo de logo+iconos (antes competia por
|
||||||
|
espacio en una sola fila con el grid_[auto_1fr_auto] y quedaba
|
||||||
|
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
||||||
|
original -- ahi sobra espacio y partirla en dos solo alargaria
|
||||||
|
el header sin necesidad. */}
|
||||||
|
<div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
|
||||||
|
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
|
||||||
{/* Isotipo circular con icono + wordmark integrados (como en la
|
{/* Isotipo circular con icono + wordmark integrados (como en la
|
||||||
version previa a ADR 0008), pedido explicito del usuario en
|
version previa a ADR 0008), pedido explicito del usuario en
|
||||||
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
||||||
mantiene para el footer. */}
|
mantiene para el footer. */}
|
||||||
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
||||||
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
|
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
<PredictiveSearch />
|
<PredictiveSearch />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
{/* Antes escondido en mobile (solo alcanzable desde el menu
|
||||||
|
hamburguesa) -- pedido explicito del usuario: visible
|
||||||
|
siempre en el header, con un punto verde cuando hay sesion
|
||||||
|
iniciada (mismo patron que el badge de cantidad del
|
||||||
|
carrito/favoritos, pero de presencia, no de conteo). */}
|
||||||
|
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
|
||||||
|
<UserRound />
|
||||||
|
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
||||||
|
</Button>
|
||||||
|
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
|
||||||
|
Cristian / Cuenta y listas" de Amazon): en mobile no hay
|
||||||
|
lugar y los iconos solos ya se entienden. */}
|
||||||
|
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
|
||||||
|
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
|
||||||
|
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
|
||||||
|
</button>
|
||||||
|
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
|
||||||
|
<span className="block text-xs text-slate-500">Cambios y</span>
|
||||||
|
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
|
||||||
|
</Link>
|
||||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
||||||
<Heart />
|
<Heart />
|
||||||
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||||
@@ -73,10 +130,14 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 lg:hidden">
|
||||||
|
<PredictiveSearch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
|
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
||||||
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
|
<DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
@@ -92,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{collections.length > 0 && (
|
{franchises.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
|
||||||
|
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
|
||||||
|
</Link>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">
|
||||||
|
{saga.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{franchises.length === 0 && collections.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -108,19 +190,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
|
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
|
||||||
<DialogContent className="max-w-lg p-7">
|
|
||||||
<div className="pr-10">
|
|
||||||
<span className="text-xs font-black uppercase tracking-[.16em] text-ari-coral">Atención personalizada</span>
|
|
||||||
<h2 className="mt-3 text-3xl font-black">Tu cuenta ARI</h2>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-600">El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.</p>
|
|
||||||
<div className="mt-6 grid gap-3">
|
|
||||||
<Button asChild variant="primary" size="lg"><a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer"><MessageCircle />Hablar con ARI</a></Button>
|
|
||||||
<Button asChild variant="outline"><Link href="/catalog" onClick={() => setAccountOpen(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,16 +199,32 @@ function useCategoryNavigationState() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentCategory = searchParams.get("category") ?? "";
|
const currentCategory = searchParams.get("category") ?? "";
|
||||||
|
const currentCollection = searchParams.get("collection") ?? "";
|
||||||
|
const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" };
|
||||||
|
const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje);
|
||||||
const isCatalog = pathname === "/catalog";
|
const isCatalog = pathname === "/catalog";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAllActive: isCatalog && !currentCategory,
|
isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
|
||||||
isActive: (name: string) => isCatalog && currentCategory === name,
|
isActive: (name: string) => isCatalog && currentCategory === name,
|
||||||
|
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
|
||||||
|
isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name,
|
||||||
|
anyTaxonomyActive: isCatalog && anyTaxonomy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pillClass(active: boolean): string {
|
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
|
||||||
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
|
|
||||||
|
const INACTIVE_NAVIGATION: NavigationState = {
|
||||||
|
isAllActive: false,
|
||||||
|
isActive: () => false,
|
||||||
|
isCollectionActive: () => false,
|
||||||
|
isTaxonomyActive: () => false,
|
||||||
|
anyTaxonomyActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function pillClass(active: boolean, dense = false): string {
|
||||||
|
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
|
||||||
active
|
active
|
||||||
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
||||||
: "text-white/90 hover:bg-white/10 hover:text-white"
|
: "text-white/90 hover:bg-white/10 hover:text-white"
|
||||||
@@ -153,26 +239,26 @@ function mobileLinkClass(active: boolean): string {
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
|
type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
|
||||||
const { isActive, isAllActive } = useCategoryNavigationState();
|
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
|
function DesktopCategoryNavigation(props: NavBarProps) {
|
||||||
|
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
|
function DesktopCategoryNavigationFallback(props: NavBarProps) {
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
|
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryNavBar({
|
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
|
||||||
tree,
|
// (cada una con su desplegable de subcategorias) -- antes solo se veian
|
||||||
onOpenMenu,
|
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
|
||||||
isActive,
|
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
|
||||||
isAllActive,
|
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
||||||
}: {
|
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
||||||
tree: CategoryNode[];
|
function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||||
onOpenMenu: () => void;
|
const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
|
||||||
isActive: (name: string) => boolean;
|
const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
|
||||||
isAllActive: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-ari-marino text-white">
|
<nav className="bg-ari-marino text-white">
|
||||||
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
||||||
@@ -181,24 +267,211 @@ function CategoryNavBar({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={onOpenMenu}
|
onClick={onOpenMenu}
|
||||||
aria-label="Abrir menú principal"
|
aria-label="Abrir menú principal"
|
||||||
className="mr-2 shrink-0 text-white hover:bg-white/10"
|
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
|
||||||
>
|
>
|
||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="shrink-0 xl:hidden">
|
||||||
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
||||||
Todo
|
Todo
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-0.5 xl:flex">
|
||||||
|
{tree.map((root) => {
|
||||||
|
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
|
||||||
|
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
|
||||||
|
if (root.children.length === 0) {
|
||||||
|
return (
|
||||||
|
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
|
||||||
|
{root.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavDropdown key={root.id} label={root.name} active={active}>
|
||||||
|
{(close) => (
|
||||||
|
<ul className="grid min-w-56 gap-1">
|
||||||
|
{root.children.map((child) => (
|
||||||
|
<li key={child.id}>
|
||||||
|
<Link
|
||||||
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isActive(child.name) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="mt-1 border-t border-white/10 pt-2">
|
||||||
|
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
|
||||||
|
Ver todo en {root.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
|
||||||
|
{(franchises.length > 0 || collections.length > 0) && (
|
||||||
|
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
|
||||||
|
{(close) => franchises.length > 0 ? (
|
||||||
|
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
|
||||||
|
) : (
|
||||||
|
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
|
||||||
|
{collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection}
|
||||||
|
href={`/catalog?collection=${encodeURIComponent(collection)}`}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isCollectionActive(collection) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
|
||||||
|
>
|
||||||
|
{collection}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
)}
|
||||||
|
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
|
||||||
|
de ayuda que la tienda ya tiene (antes solo en el footer). */}
|
||||||
|
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
|
||||||
|
{(close) => (
|
||||||
|
<ul className="grid min-w-60 gap-1">
|
||||||
|
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
|
||||||
|
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
|
||||||
|
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
|
||||||
|
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
|
||||||
|
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
|
||||||
|
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
|
||||||
|
<li className="mt-1 border-t border-white/10 pt-2">
|
||||||
|
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</NavDropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
|
||||||
|
// alternara, cerraria el menu que se acaba de abrir por hover y para el
|
||||||
|
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
|
||||||
|
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
|
||||||
|
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
|
||||||
|
// touch (sin hover) el click sigue alternando normal.
|
||||||
|
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
|
||||||
|
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
|
||||||
|
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
|
||||||
|
// solo en tablets (iPad horizontal cae en el rango del mega menu).
|
||||||
|
// Reportado por frontend-critic con log de eventos del navegador.
|
||||||
|
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
|
||||||
|
if (open && Date.now() - hoverOpenedAt < 600) return true;
|
||||||
|
return !open;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
|
||||||
|
// Earth): una columna por franquicia con su total, sus sagas y los
|
||||||
|
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
|
||||||
|
// "Spider-Man" solo lo de Spider-Man.
|
||||||
|
function FranchisePanel({
|
||||||
|
franchises,
|
||||||
|
isTaxonomyActive,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
franchises: FranchiseNode[];
|
||||||
|
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const link = (kind: TaxonomyKind, name: string, base: string) => (
|
||||||
|
<Link
|
||||||
|
href={taxonomyHref(kind, name)}
|
||||||
|
prefetch={false}
|
||||||
|
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
|
||||||
|
onClick={close}
|
||||||
|
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
|
||||||
|
{franchises.map((franchise) => (
|
||||||
|
<div key={franchise.name}>
|
||||||
|
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
|
||||||
|
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
|
||||||
|
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2.5 grid gap-2.5">
|
||||||
|
{franchise.sagas.map((saga) => (
|
||||||
|
<li key={saga.name}>
|
||||||
|
{link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")}
|
||||||
|
{saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
{saga.characters.map((character) => (
|
||||||
|
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
|
||||||
|
|
||||||
|
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
|
||||||
|
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
|
||||||
|
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
|
||||||
|
function NavDropdown({
|
||||||
|
label,
|
||||||
|
labelClassName,
|
||||||
|
ariaLabel,
|
||||||
|
active,
|
||||||
|
align = "left",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
active: boolean;
|
||||||
|
align?: "left" | "right";
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: (close: () => void) => ReactNode;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const cancelClose = () => {
|
||||||
|
if (closeTimer.current) {
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
closeTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleClose = () => {
|
||||||
|
cancelClose();
|
||||||
|
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -206,7 +479,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
}
|
}
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") setOpen(false);
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
// Devolver el foco al boton que abrio el menu: si no, queda en
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -216,22 +495,111 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelClose(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
|
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
aria-label={ariaLabel}
|
||||||
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className={labelClassName}>{label}</span>
|
||||||
|
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverOpenedAt = useRef(0);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const cancelClose = () => {
|
||||||
|
if (closeTimer.current) {
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
closeTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Delay corto en vez de cerrar al toque: aunque el gap entre el boton y
|
||||||
|
// el panel ya se elimino (antes era la causa real -- el panel es
|
||||||
|
// absolute y no suma a la altura del contenedor, asi que un mt-1 ahi
|
||||||
|
// dejaba una franja muerta que cerraba el menu a mitad de camino,
|
||||||
|
// reportado en vivo), este margen extra tolera movimientos rapidos o en
|
||||||
|
// diagonal cerca del borde sin reabrir nada raro.
|
||||||
|
const scheduleClose = () => {
|
||||||
|
cancelClose();
|
||||||
|
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
// Devolver el foco al boton que abrio el menu: si no, queda en
|
||||||
|
// <body> y quien navega con teclado pierde su posicion (reportado
|
||||||
|
// por frontend-critic).
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelClose(), []);
|
||||||
|
|
||||||
|
const openNow = () => {
|
||||||
|
cancelClose();
|
||||||
|
if (!open) hoverOpenedAt.current = Date.now();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-label="Departamentos"
|
||||||
className={`flex items-center gap-1.5 ${pillClass(open)}`}
|
className={`flex items-center gap-1.5 ${pillClass(open)}`}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="size-4" />
|
<LayoutGrid className="size-4" />
|
||||||
Departamentos
|
{/* Texto oculto en mobile: los 3 items de esta fila (menu, este
|
||||||
|
boton, "Todo") tienen shrink-0 -- ninguno se achica, asi que si
|
||||||
|
los tres no entran, desbordan la pagina entera (confirmado en
|
||||||
|
vivo 2026-09-20 contra 320px real: 14px de overflow horizontal
|
||||||
|
con el texto visible). No es un caso de min-w-0 faltante (ese
|
||||||
|
fix asume que el item PUEDE achicarse) -- ocultar la palabra
|
||||||
|
mas larga de la fila es lo que de verdad libera espacio. */}
|
||||||
|
<span className="hidden sm:inline">Departamentos</span>
|
||||||
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
|
<div className="absolute left-0 top-full z-50 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
{tree.map((root) => (
|
{tree.map((root) => (
|
||||||
<div key={root.id}>
|
<div key={root.id}>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { StarRating } from "@/components/product/star-rating";
|
||||||
|
import { useReviewSummaryStore } from "@/store/review-summary-store";
|
||||||
|
|
||||||
|
// Promedio + cantidad de reseñas aprobadas. No renderiza nada si el
|
||||||
|
// producto aun no tiene reseñas (ni un link vacio, en la ficha).
|
||||||
|
export function CardRating({ productId, href }: { productId: string; href?: string }) {
|
||||||
|
const load = useReviewSummaryStore((state) => state.load);
|
||||||
|
const entry = useReviewSummaryStore((state) => state.summary[productId]);
|
||||||
|
useEffect(() => load(), [load]);
|
||||||
|
if (!entry || entry.count === 0) return null;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<StarRating value={entry.average} />
|
||||||
|
{entry.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })} ({entry.count} {entry.count === 1 ? "reseña" : "reseñas"})
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const className = "flex items-center gap-1.5 text-xs font-bold text-slate-500";
|
||||||
|
return href ? <a href={href} className={`${className} mt-3 hover:text-ari-coral`}>{content}</a> : <span className={className}>{content}</span>;
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ShoppingBag } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { Product } from "@/lib/headless/types";
|
|
||||||
import { useCartStore } from "@/store/cart-store";
|
|
||||||
|
|
||||||
export function MobileAddToCart({ product }: { product: Product }) {
|
|
||||||
const addItem = useCartStore((state) => state.addItem);
|
|
||||||
return <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"><Button variant="primary" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
// Galería deslizable de la tarjeta de producto: permite ver todas las
|
||||||
|
// imágenes desde el listado sin entrar al producto. En móvil se desliza
|
||||||
|
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
|
||||||
|
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
|
||||||
|
export function ProductCardGallery({
|
||||||
|
images,
|
||||||
|
name,
|
||||||
|
href,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
images: string[];
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const total = images.length;
|
||||||
|
|
||||||
|
const goTo = (target: number) => {
|
||||||
|
const node = scroller.current;
|
||||||
|
if (!node) return;
|
||||||
|
const next = Math.max(0, Math.min(total - 1, target));
|
||||||
|
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const node = scroller.current;
|
||||||
|
if (!node || node.clientWidth === 0) return;
|
||||||
|
setIndex(Math.round(node.scrollLeft / node.clientWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
onScroll={total > 1 ? onScroll : undefined}
|
||||||
|
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{images.map((src, slide) => (
|
||||||
|
<Link
|
||||||
|
key={src}
|
||||||
|
href={href}
|
||||||
|
prefetch={false}
|
||||||
|
className="relative block h-full w-full shrink-0 snap-center snap-always"
|
||||||
|
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
|
||||||
|
tabIndex={slide === 0 ? 0 : -1}
|
||||||
|
>
|
||||||
|
{/* object-contain (no object-cover): las fotos secundarias del
|
||||||
|
catálogo no son cuadradas (verticales tipo 933x1500 u
|
||||||
|
horizontales) y cover les cortaba cabeza, pies o bordes
|
||||||
|
-- reportado en vivo con Tristeza y Buzz. Mismo criterio que
|
||||||
|
product-gallery.tsx; el letterbox queda del bg-white de la
|
||||||
|
tarjeta. */}
|
||||||
|
{/* unoptimized desde la 2da imagen: pasar todas por el
|
||||||
|
optimizador de Next multiplica el trabajo de sharp dentro
|
||||||
|
del pod (~58 imagenes en el catalogo vs. 18 antes) -- una
|
||||||
|
carga en rafaga de todas las galerias dejo un pod de prod
|
||||||
|
OOMKilled (limite 768Mi) el 2026-09-27. Los originales de
|
||||||
|
media.cruzcloud.net pesan ~165 KB promedio y son lazy: solo
|
||||||
|
se bajan al deslizar. La 1ra sigue optimizada como antes. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
|
||||||
|
fill
|
||||||
|
unoptimized={slide > 0}
|
||||||
|
priority={priority && slide === 0}
|
||||||
|
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
|
||||||
|
className="object-contain transition duration-500 group-hover:scale-[1.035]"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Imagen anterior"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => goTo(index - 1)}
|
||||||
|
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Imagen siguiente"
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={() => goTo(index + 1)}
|
||||||
|
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||||
|
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
|
||||||
|
{images.map((src, dot) => (
|
||||||
|
<button
|
||||||
|
key={src}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Ver imagen ${dot + 1} de ${total}`}
|
||||||
|
aria-current={dot === index}
|
||||||
|
onClick={() => goTo(dot)}
|
||||||
|
className="grid h-5 place-items-center px-[3px]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-1.5 rounded-full transition-all ${
|
||||||
|
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
|
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CardRating } from "@/components/product/card-rating";
|
||||||
|
import { ProductCardGallery } from "@/components/product/product-card-gallery";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Product } from "@/lib/headless/types";
|
||||||
@@ -22,9 +23,14 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
? Math.round((1 - product.price / product.compareAtPrice) * 100)
|
? Math.round((1 - product.price / product.compareAtPrice) * 100)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
|
||||||
|
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
|
||||||
|
|
||||||
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
|
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
|
||||||
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
|
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
|
||||||
|
const outOfStock = selectedVariant?.available === false;
|
||||||
const lowStock =
|
const lowStock =
|
||||||
|
!outOfStock &&
|
||||||
selectedVariant?.inventoryQuantity !== null &&
|
selectedVariant?.inventoryQuantity !== null &&
|
||||||
selectedVariant?.inventoryQuantity !== undefined &&
|
selectedVariant?.inventoryQuantity !== undefined &&
|
||||||
selectedVariant.inventoryQuantity > 0 &&
|
selectedVariant.inventoryQuantity > 0 &&
|
||||||
@@ -35,13 +41,15 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
return (
|
return (
|
||||||
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
|
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
|
||||||
<div className="relative aspect-square overflow-hidden bg-white">
|
<div className="relative aspect-square overflow-hidden bg-white">
|
||||||
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
|
<ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
|
||||||
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
<div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||||
|
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
|
||||||
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
|
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
|
||||||
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
|
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
|
||||||
|
<CardRating productId={product.id} />
|
||||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||||
{product.variants.length > 1 && (
|
{product.variants.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -67,12 +75,15 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
{lowStock !== null && (
|
{lowStock !== null && (
|
||||||
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
|
<span className="flex items-center gap-1.5 text-xs font-bold text-amber-600"><TriangleAlert className="size-3.5" />Quedan {lowStock}</span>
|
||||||
)}
|
)}
|
||||||
|
{outOfStock && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-bold text-rose-600"><TriangleAlert className="size-3.5" />Sin stock</span>
|
||||||
|
)}
|
||||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
|
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
|
{product.compareAtPrice && <span className="text-xs font-bold text-slate-400 line-through">{formatMoney(product.compareAtPrice, product.currency)}</span>}
|
||||||
<strong className="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong>
|
<strong className="font-display text-base font-bold text-ari-marino">{formatMoney(product.price, product.currency)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />Agregar</Button>
|
<Button variant="primary" size="sm" disabled={outOfStock} onClick={() => addItem(product, selectedVariantId)}><ShoppingBag className="size-4" />{outOfStock ? "Agotado" : "Agregar"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -2,19 +2,90 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
|
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
|
||||||
import { PointerEvent, WheelEvent, useRef, useState } from "react";
|
import { MouseEvent, PointerEvent, WheelEvent, useRef, useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
|
||||||
interface Offset { x: number; y: number }
|
interface Offset { x: number; y: number }
|
||||||
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
||||||
|
interface HoverZoomState {
|
||||||
|
lensLeft: number;
|
||||||
|
lensTop: number;
|
||||||
|
lensSize: number;
|
||||||
|
bgX: number;
|
||||||
|
bgY: number;
|
||||||
|
showPanel: boolean;
|
||||||
|
panelLeft: number;
|
||||||
|
panelTop: number;
|
||||||
|
panelSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la
|
||||||
|
// imagen (la lupa) marca la region que se esta mirando, y un panel
|
||||||
|
// aparte a la derecha muestra esa misma region ampliada -- sin recortar
|
||||||
|
// nada de la pantalla ni pedirle al usuario que haga scroll para verlo
|
||||||
|
// completo (a diferencia de un primer intento que escalaba la imagen
|
||||||
|
// in-place con transform, donde para ver el borde superior había que
|
||||||
|
// mover el cursor fuera del viewport visible). Solo mouse (mousemove no
|
||||||
|
// dispara de forma continua en touch); en mobile no aparece el efecto,
|
||||||
|
// sin romper nada. Independiente del zoom interactivo del modal (click
|
||||||
|
// -> Dialog con scale/drag), que sigue igual.
|
||||||
|
//
|
||||||
|
// El panel usa background-image con la foto completa (no la version ya
|
||||||
|
// recortada por object-cover) a proposito -- escalar la imagen recortada
|
||||||
|
// nunca iba a revelar las partes recortadas (ej. la cabeza de una figura
|
||||||
|
// de accion en una foto vertical). background-size en un solo valor
|
||||||
|
// (ancho%, alto en "auto") preserva el aspect ratio real de la foto
|
||||||
|
// completa.
|
||||||
|
//
|
||||||
|
// El tamaño/posicion del panel se calculan en cada mousemove contra el
|
||||||
|
// viewport real (window.innerWidth/innerHeight), no con un breakpoint
|
||||||
|
// fijo tipo "lg:block" -- a resoluciones altas con la ventana angosta
|
||||||
|
// (o el layout de la pagina dejando poco espacio a la derecha de la
|
||||||
|
// imagen) un tamaño fijo terminaba saliéndose de la pantalla. Si no
|
||||||
|
// entra un panel de al menos PANEL_MIN_SIZE, se cae automáticamente al
|
||||||
|
// overlay in-place (misma imagen completa como fondo, dentro de la
|
||||||
|
// propia caja) en vez de mostrar un panel cortado.
|
||||||
|
const HOVER_ZOOM_SCALE = 2.2;
|
||||||
|
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE;
|
||||||
|
const PANEL_MARGIN = 16;
|
||||||
|
const PANEL_MAX_SIZE = 480;
|
||||||
|
const PANEL_MIN_SIZE = 220;
|
||||||
|
const VIEWPORT_EDGE_PADDING = 16;
|
||||||
|
|
||||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||||
const [selected, setSelected] = useState(images[0] ?? "");
|
const [selected, setSelected] = useState(images[0] ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
||||||
|
const [hoverZoom, setHoverZoom] = useState<HoverZoomState | null>(null);
|
||||||
const drag = useRef<DragState | null>(null);
|
const drag = useRef<DragState | null>(null);
|
||||||
|
|
||||||
|
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const lensSize = rect.width * LENS_RATIO;
|
||||||
|
const cursorX = event.clientX - rect.left;
|
||||||
|
const cursorY = event.clientY - rect.top;
|
||||||
|
const lensLeft = Math.min(Math.max(cursorX - lensSize / 2, 0), rect.width - lensSize);
|
||||||
|
const lensTop = Math.min(Math.max(cursorY - lensSize / 2, 0), rect.height - lensSize);
|
||||||
|
|
||||||
|
const availableWidth = window.innerWidth - rect.right - PANEL_MARGIN - VIEWPORT_EDGE_PADDING;
|
||||||
|
const availableHeight = window.innerHeight - VIEWPORT_EDGE_PADDING * 2;
|
||||||
|
const panelSize = Math.min(PANEL_MAX_SIZE, availableWidth, availableHeight);
|
||||||
|
const panelTop = Math.min(Math.max(rect.top, VIEWPORT_EDGE_PADDING), window.innerHeight - panelSize - VIEWPORT_EDGE_PADDING);
|
||||||
|
|
||||||
|
setHoverZoom({
|
||||||
|
lensLeft,
|
||||||
|
lensTop,
|
||||||
|
lensSize,
|
||||||
|
bgX: (lensLeft / (rect.width - lensSize)) * 100,
|
||||||
|
bgY: (lensTop / (rect.height - lensSize)) * 100,
|
||||||
|
showPanel: panelSize >= PANEL_MIN_SIZE,
|
||||||
|
panelLeft: rect.right + PANEL_MARGIN,
|
||||||
|
panelTop,
|
||||||
|
panelSize,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const applyScale = (next: number) => {
|
const applyScale = (next: number) => {
|
||||||
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
|
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
|
||||||
setScale(value);
|
setScale(value);
|
||||||
@@ -38,14 +109,93 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
|
<div className="min-w-0 grid gap-4 lg:grid-cols-[86px_1fr]">
|
||||||
|
{/* min-w-0: esta tira de miniaturas es un overflow-x-auto (scroll
|
||||||
|
horizontal) igual que el carrusel de "También te puede gustar"
|
||||||
|
en app/product/[slug]/page.tsx -- mismo bug si este componente
|
||||||
|
termina como item de un grid externo sin min-w-0: para productos
|
||||||
|
con muchas fotos, el ancho minimo intrinseco de TODAS las
|
||||||
|
miniaturas sin wrappear puede forzar overflow horizontal en toda
|
||||||
|
la pagina en mobile. */}
|
||||||
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
||||||
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}
|
{/* object-contain (no object-cover): fotos de producto con relacion
|
||||||
|
de aspecto muy vertical (ej. 933x1500, confirmado en vivo para
|
||||||
|
este catalogo) perdian la cabeza o los pies recortados al forzar
|
||||||
|
el cuadrado con cover -- reportado por el usuario viendo las
|
||||||
|
miniaturas. contain deja la miniatura completa siempre, con
|
||||||
|
"letterbox" del mismo bg-slate-50 que usa la imagen principal. */}
|
||||||
|
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} onMouseEnter={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 bg-slate-50 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-contain" /></button>)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative order-1 lg:order-2">
|
||||||
<Dialog open={open} onOpenChange={changeOpen}>
|
<Dialog open={open} onOpenChange={changeOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button type="button" className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
|
<button
|
||||||
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
|
type="button"
|
||||||
|
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
|
||||||
|
onMouseMove={trackHoverZoom}
|
||||||
|
onMouseLeave={() => setHoverZoom(null)}
|
||||||
|
>
|
||||||
|
{/* unoptimized a proposito: confirmado en vivo (createImageBitmap
|
||||||
|
sobre los bytes reales vs. el <img> renderizado) que a DPR
|
||||||
|
alto (2.6+, cualquier Android real) Chromium reporta
|
||||||
|
naturalWidth/Height de un candidato del srcset mucho más
|
||||||
|
chico del que dice currentSrc -- bug del propio motor de
|
||||||
|
Chromium en la selección de candidato con sizes="100vw" en
|
||||||
|
fill, no algo corrupto en el archivo ni en nuestro caché
|
||||||
|
(el archivo decodifica perfecto vía fetch+createImageBitmap
|
||||||
|
fuera de <img>). Reproducido igual en Chrome y Edge Android
|
||||||
|
(ambos Chromium) y en Chromium headless. unoptimized deja
|
||||||
|
un solo recurso real por foto -- sin candidatos entre los
|
||||||
|
que el bug pueda elegir mal. */}
|
||||||
|
{/* object-contain (no object-cover): mismo motivo que las
|
||||||
|
miniaturas -- fotos verticales (933x1500 confirmado en
|
||||||
|
este catalogo) perdian la cabeza al forzar el cuadrado.
|
||||||
|
Reportado en vivo: al seleccionar esa foto, Buzz aparecia
|
||||||
|
sin cabeza en la imagen principal. */}
|
||||||
|
<Image
|
||||||
|
src={selected}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
{/* Lupa: recuadro que marca la region ampliada -- solo cuando
|
||||||
|
hay lugar real para el panel de al lado (showPanel,
|
||||||
|
calculado contra el viewport real, no un breakpoint
|
||||||
|
fijo). Sin espacio para el panel se usa el overlay
|
||||||
|
in-place como fallback, para cualquier resolucion. */}
|
||||||
|
{hoverZoom?.showPanel && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
data-testid="gallery-lens"
|
||||||
|
className="pointer-events-none absolute border-2 border-white bg-white/25 shadow-lg"
|
||||||
|
style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
data-testid="gallery-inplace-overlay"
|
||||||
|
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
|
||||||
|
style={{
|
||||||
|
opacity: hoverZoom && !hoverZoom.showPanel ? 1 : 0,
|
||||||
|
// Comillas dentro de url() a proposito: sin ellas, un
|
||||||
|
// espacio o parentesis literal en el nombre de archivo
|
||||||
|
// (ej. "descarga (4)-xxx.png", el patron por defecto de
|
||||||
|
// Chrome al descargar una imagen sin renombrarla -- asi
|
||||||
|
// quedan las subidas manuales, a diferencia de las
|
||||||
|
// importadas por CSV) rompe la sintaxis de url() sin
|
||||||
|
// comillas -- el navegador descarta el valor completo en
|
||||||
|
// silencio (backgroundImage queda "none"). Confirmado en
|
||||||
|
// vivo 2026-09-20: reportado por el usuario como "el zoom
|
||||||
|
// interactivo no se visualiza" solo en fotos subidas a
|
||||||
|
// mano por el admin, nunca en las de CSV.
|
||||||
|
backgroundImage: `url("${selected}")`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
||||||
|
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -53,7 +203,7 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
|
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
|
||||||
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
|
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
|
||||||
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
||||||
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
|
<Image src={selected} alt={name} fill unoptimized className="select-none object-contain" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
|
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +217,27 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{hoverZoom?.showPanel && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
data-testid="gallery-zoom-panel"
|
||||||
|
className="pointer-events-none fixed z-30 overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl"
|
||||||
|
style={{
|
||||||
|
left: hoverZoom.panelLeft,
|
||||||
|
top: hoverZoom.panelTop,
|
||||||
|
width: hoverZoom.panelSize,
|
||||||
|
height: hoverZoom.panelSize,
|
||||||
|
// Ver el comentario en el overlay in-place de arriba -- mismo
|
||||||
|
// fix, mismo motivo (comillas necesarias dentro de url()).
|
||||||
|
backgroundImage: `url("${selected}")`,
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
||||||
|
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck } from "lucide-react";
|
import { CheckCircle2, RotateCcw, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Product } from "@/lib/headless/types";
|
||||||
@@ -11,12 +12,86 @@ export function ProductPurchase({ product }: { product: Product }) {
|
|||||||
const [variantId, setVariantId] = useState(product.variants[0]?.id);
|
const [variantId, setVariantId] = useState(product.variants[0]?.id);
|
||||||
const addItem = useCartStore((state) => state.addItem);
|
const addItem = useCartStore((state) => state.addItem);
|
||||||
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
|
const selectedVariant = product.variants.find((variant) => variant.id === variantId) ?? product.variants[0];
|
||||||
|
const outOfStock = selectedVariant?.available === false;
|
||||||
const lowStock =
|
const lowStock =
|
||||||
|
!outOfStock &&
|
||||||
selectedVariant?.inventoryQuantity !== null &&
|
selectedVariant?.inventoryQuantity !== null &&
|
||||||
selectedVariant?.inventoryQuantity !== undefined &&
|
selectedVariant?.inventoryQuantity !== undefined &&
|
||||||
selectedVariant.inventoryQuantity > 0 &&
|
selectedVariant.inventoryQuantity > 0 &&
|
||||||
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
|
selectedVariant.inventoryQuantity <= LOW_STOCK_THRESHOLD
|
||||||
? selectedVariant.inventoryQuantity
|
? selectedVariant.inventoryQuantity
|
||||||
: null;
|
: null;
|
||||||
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div>{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */}<Button variant="primary" size="lg" className="hidden w-full lg:inline-flex" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">{lowStock !== null ? <span className="flex items-center gap-2 font-bold text-amber-600"><TriangleAlert className="size-4" />Quedan {lowStock} unidades</span> : <span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span>}<span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-5 flex flex-wrap gap-2">
|
||||||
|
{product.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => setVariantId(variant.id)}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm font-bold ${
|
||||||
|
variantId === variant.id ? "border-ari-marino bg-ari-marino text-white" : "border-slate-200"
|
||||||
|
} ${variant.available === false ? "opacity-40" : ""}`}
|
||||||
|
>
|
||||||
|
{variant.value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 flex items-end gap-3">
|
||||||
|
<strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>
|
||||||
|
{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}
|
||||||
|
</div>
|
||||||
|
{/* Un solo botón, visible también en mobile -- sin barra flotante
|
||||||
|
aparte (se probó, pero terminaba tapando contenido de abajo al
|
||||||
|
scrollear, ej. "También te puede interesar"; el usuario pidió
|
||||||
|
sacarla y dejar el botón normal como en la mayoría de tiendas). */}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
disabled={outOfStock}
|
||||||
|
onClick={() => addItem(product, variantId)}
|
||||||
|
>
|
||||||
|
<ShoppingBag />
|
||||||
|
{outOfStock ? "Agotado" : "Añadir al carrito"}
|
||||||
|
</Button>
|
||||||
|
<div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm">
|
||||||
|
{outOfStock ? (
|
||||||
|
<span className="flex items-center gap-2 font-bold text-rose-600">
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
Agotado
|
||||||
|
</span>
|
||||||
|
) : lowStock !== null ? (
|
||||||
|
<span className="flex items-center gap-2 font-bold text-amber-600">
|
||||||
|
<TriangleAlert className="size-4" />
|
||||||
|
Quedan {lowStock} unidades
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 font-bold">
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||||
|
Disponibilidad por confirmar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-2 font-bold">
|
||||||
|
<Truck className="size-4 text-cyan-600" />
|
||||||
|
Envíos nacionales
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Franja de confianza real (estilo Walmart) -- enlaza a las
|
||||||
|
páginas reales de /envios y /cambios en vez de repetir texto
|
||||||
|
suelto, con los datos reales que confirmó el usuario (5 días
|
||||||
|
hábiles, envío de vuelta a cargo de ARI Shopping). Nada
|
||||||
|
inventado: mismas políticas ya publicadas en el footer. */}
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
<Link href="/envios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
|
||||||
|
<Truck className="size-4 text-cyan-600" />
|
||||||
|
Envíos calculados en el checkout · ver detalles
|
||||||
|
</Link>
|
||||||
|
<Link href="/cambios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
|
||||||
|
<RotateCcw className="size-4 text-ari-marino" />
|
||||||
|
Cambios en 5 días hábiles, envío de vuelta gratis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BadgeCheck, MessageSquareText } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StarRating } from "@/components/product/star-rating";
|
||||||
|
|
||||||
|
type Review = { id: string; author_name: string; rating: number; title: string | null; content: string; created_at: string; verified_purchase: boolean };
|
||||||
|
|
||||||
|
// Reseñas de la ficha: solo las aprobadas por el dueño (el backend no
|
||||||
|
// devuelve las pendientes), siempre de compradores verificados.
|
||||||
|
export function ProductReviews({ productId }: { productId: string }) {
|
||||||
|
const [data, setData] = useState<{ reviews: Review[]; average: number; count: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/reviews?product_id=${encodeURIComponent(productId)}`)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setData({ reviews: [], average: 0, count: 0 }));
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="resenas" aria-labelledby="resenas-titulo" className="mt-10 scroll-mt-40">
|
||||||
|
<h2 id="resenas-titulo" className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-slate-500">
|
||||||
|
<MessageSquareText className="size-4" />
|
||||||
|
Reseñas de clientes
|
||||||
|
</h2>
|
||||||
|
{!data ? (
|
||||||
|
<p className="mt-4 text-sm text-slate-400">Cargando reseñas…</p>
|
||||||
|
) : data.count === 0 ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 p-5 text-sm text-slate-600">
|
||||||
|
Aún no hay reseñas de este producto. ¿Ya lo compraste?{" "}
|
||||||
|
<Link href="/cuenta/pedidos" className="font-bold text-ari-marino underline decoration-ari-coral-claro underline-offset-2">Califícalo desde Mis pedidos</Link>.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<StarRating value={data.average} size="lg" />
|
||||||
|
<span className="text-2xl font-black text-ari-marino">{data.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })}</span>
|
||||||
|
<span className="text-sm text-slate-500">de 5 · {data.count} {data.count === 1 ? "reseña" : "reseñas"}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 grid gap-4">
|
||||||
|
{data.reviews.map((review) => (
|
||||||
|
<li key={review.id} className="rounded-2xl border border-slate-200 p-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<StarRating value={review.rating} />
|
||||||
|
{review.title && <strong className="text-slate-900">{review.title}</strong>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700">{review.content}</p>
|
||||||
|
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span className="font-bold text-slate-700">{review.author_name}</span>
|
||||||
|
<span>{new Date(review.created_at).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}</span>
|
||||||
|
{review.verified_purchase && (
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-emerald-700"><BadgeCheck className="size-3.5" />Compra verificada</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Star } from "lucide-react";
|
||||||
|
|
||||||
|
// Estrellas de solo lectura. El relleno parcial (ej. 4.5) se dibuja
|
||||||
|
// recortando una capa de estrellas llenas encima de las vacias. amber-600
|
||||||
|
// (no 400): contraste ~3.2:1 sobre blanco, minimo WCAG 1.4.11 para un
|
||||||
|
// grafico informativo (amber-400 daba 1.7:1, reportado por frontend-critic).
|
||||||
|
export function StarRating({ value, size = "sm", className = "" }: { value: number; size?: "sm" | "md" | "lg"; className?: string }) {
|
||||||
|
const px = size === "lg" ? "size-6" : size === "md" ? "size-5" : "size-4";
|
||||||
|
const pct = Math.max(0, Math.min(100, (value / 5) * 100));
|
||||||
|
return (
|
||||||
|
<span className={`relative inline-flex ${className}`} role="img" aria-label={`${value.toLocaleString("es-CO")} de 5 estrellas`}>
|
||||||
|
<span className="flex text-slate-300">{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} fill-current`} />)}</span>
|
||||||
|
<span className="absolute inset-0 flex overflow-hidden text-amber-600" style={{ width: `${pct}%` }}>
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} shrink-0 fill-current`} />)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,6 +87,18 @@ export function PredictiveSearch() {
|
|||||||
Buscando…
|
Buscando…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Encontrado probando en vivo con navegador real: sin este bloque,
|
||||||
|
una busqueda de 2+ caracteres sin coincidencias no mostraba nada
|
||||||
|
(ni "buscando" ni un mensaje) -- el usuario se quedaba sin ningun
|
||||||
|
feedback de que su busqueda de verdad corrio. Se ofrece igual el
|
||||||
|
boton de buscar en todo el catalogo porque esa busqueda completa
|
||||||
|
puede usar criterios mas amplios que el autocompletado. */}
|
||||||
|
{!loading && normalized.length >= 2 && matches.length === 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 rounded-2xl border border-slate-200 bg-white p-2 text-center shadow-2xl">
|
||||||
|
<p className="p-3 text-sm text-slate-500">No encontramos resultados para “{query.trim()}”.</p>
|
||||||
|
<button type="submit" className="w-full rounded-xl bg-ari-marino px-4 py-3 text-sm font-black text-white hover:brightness-110">Buscar “{query.trim()}” en todo el catálogo</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{matches.length > 0 && (
|
{matches.length > 0 && (
|
||||||
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
||||||
{matches.map((product) => (
|
{matches.map((product) => (
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.209
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
|
||||||
env:
|
env:
|
||||||
|
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
|
||||||
|
# que bumpea el tag de la imagen -- el footer la lee
|
||||||
|
# (process.env.APP_VERSION) para mostrar la versión real
|
||||||
|
# desplegada en vez de un "v4.1" fijo.
|
||||||
|
- name: APP_VERSION
|
||||||
|
value: v1.0.293
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
- name: MEDUSA_BACKEND_URL
|
- name: MEDUSA_BACKEND_URL
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user