Compare commits
292
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bb2c0bbe85 | ||
|
|
cdab538cfb | ||
|
|
f124ae0bbd | ||
|
|
b8ce9ded87 | ||
|
|
b12dd848af | ||
|
|
d28ee4a49d | ||
|
|
3fc77c6cdc | ||
|
|
14c473eb8b | ||
|
|
93e2401e3b | ||
|
|
615cf9ce77 | ||
|
|
5931e8933c | ||
|
|
52c628faa0 | ||
|
|
f88b72d781 | ||
|
|
525ebde5ce | ||
|
|
0f98fa286e | ||
|
|
33f510dcdd | ||
|
|
52295e2c6d | ||
|
|
fe0387cc4c | ||
|
|
cfec1543ab | ||
|
|
49a6645378 | ||
|
|
11ef812f64 | ||
|
|
4b2e6477e5 | ||
|
|
e5f9ec5f1d | ||
|
|
11b97b9178 | ||
|
|
229999fa5a | ||
|
|
8e0053ab84 | ||
|
|
e837c55de8 | ||
|
|
4994d063eb | ||
|
|
a974e738eb | ||
|
|
15483e7a5b | ||
|
|
a4dd1c444a | ||
|
|
a3913ce457 | ||
|
|
503d714ea4 | ||
|
|
afd6991519 | ||
|
|
7d27b527e2 | ||
|
|
3ad6951f44 | ||
|
|
0e57bb04d1 | ||
|
|
e631672804 | ||
|
|
ca26c6ebea | ||
|
|
12d3c692d3 | ||
|
|
9c441032de | ||
|
|
603f4b963d | ||
|
|
ac37774245 | ||
|
|
7aa9f40abc | ||
|
|
617a69d58a | ||
|
|
09a8698f7e | ||
|
|
23700c5b1d | ||
|
|
5c173d5f40 | ||
|
|
d0a3554959 | ||
|
|
75dedc4e67 | ||
|
|
801db95aa8 | ||
|
|
6df24abcb9 | ||
|
|
a6291ffe14 | ||
|
|
030bf2712a | ||
|
|
f5ddb63c8a | ||
|
|
d536b0bb8b | ||
|
|
260c7076e2 | ||
|
|
59024cd6e5 | ||
|
|
ebd341c060 | ||
|
|
e7349f34c3 | ||
|
|
c94ad5f651 | ||
|
|
ce929188ac | ||
|
|
98e0d331e9 | ||
|
|
d299fc1201 | ||
|
|
72a2715502 | ||
|
|
1ed06edbdd | ||
|
|
ffcadc5dca | ||
|
|
61c57fbf28 | ||
|
|
a31a0d96b6 | ||
|
|
ba2b290523 | ||
|
|
cf9d642764 | ||
|
|
48ef980910 | ||
|
|
53b65965f3 | ||
|
|
77044130aa | ||
|
|
2b3768bfb4 | ||
|
|
c9e8641bfb | ||
|
|
99ddebcc8d | ||
|
|
d3745c8617 | ||
|
|
b123a5a9c9 | ||
|
|
b5c2c36749 | ||
|
|
f147f4c42c | ||
|
|
d44ef5c35e | ||
|
|
467a99365d | ||
|
|
3c4046926f | ||
|
|
93fbf3ed31 | ||
|
|
635a974a6c | ||
|
|
814df2af53 | ||
|
|
6d065709c1 | ||
|
|
de5371d556 | ||
|
|
2a92dbc36c | ||
|
|
1a7f9d7e11 | ||
|
|
a01ab55ebe | ||
|
|
c1d8059c48 | ||
|
|
699598ab06 | ||
|
|
ef16858d35 | ||
|
|
9c11e16779 | ||
|
|
52ac97c3e1 | ||
|
|
45d8152317 | ||
|
|
68adb32663 | ||
|
|
f761844cd0 | ||
|
|
31fd800734 | ||
|
|
9da759617c | ||
|
|
949788cda7 | ||
|
|
056a7fb76a | ||
|
|
b7443a034f | ||
|
|
4ee892a43a | ||
|
|
c6c66b76a3 | ||
|
|
7406aff34f | ||
|
|
ba3e6c7322 | ||
|
|
884dbd1d93 | ||
|
|
951682a4d4 | ||
|
|
6b5450f16f | ||
|
|
83d448b282 | ||
|
|
1ecd2015a4 | ||
|
|
609fc19252 | ||
|
|
902350b58b | ||
|
|
7d2c03f10f | ||
|
|
72ae63f2e3 | ||
|
|
4afc63bdc6 | ||
|
|
ed9891577f | ||
|
|
41c7ad25e7 | ||
|
|
2056e6c766 | ||
|
|
f2377be69c | ||
|
|
d6b84e4480 | ||
|
|
f8f540af6a | ||
|
|
51eadd98dc | ||
|
|
fbf0aec892 | ||
|
|
e3eca46e0e | ||
|
|
586313d6d7 | ||
|
|
77a15c26b1 | ||
|
|
6be8dd409c | ||
|
|
8cd43a2584 | ||
|
|
688a7d8e97 | ||
|
|
751817ed61 | ||
|
|
ebe143107d | ||
|
|
c243f02ec4 | ||
|
|
31782a2838 | ||
|
|
9069f165c0 | ||
|
|
5ce7030c4d |
No files matched your search
@@ -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:
|
||||||
@@ -579,6 +591,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 +611,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,7 +21,28 @@ ENV CI=true \
|
|||||||
S3_ACCESS_KEY_ID=build-access-key \
|
S3_ACCESS_KEY_ID=build-access-key \
|
||||||
S3_SECRET_ACCESS_KEY=build-secret-key \
|
S3_SECRET_ACCESS_KEY=build-secret-key \
|
||||||
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_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 ./
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,91 @@ const required = (name: string): string => {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mismos campos derivados de metadata/categories/collection/variants que
|
||||||
|
// mapProduct() en workloads/ecommerce/lib/headless/providers/medusa.ts --
|
||||||
|
// el documento indexado debe reflejar exactamente lo que el storefront
|
||||||
|
// ya muestra, no una vista distinta del catálogo.
|
||||||
|
function stringMetadata(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
const value = metadata?.[key]
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanMetadata(
|
||||||
|
metadata: Record<string, unknown> | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
const value = metadata?.[key]
|
||||||
|
return value === true || value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function meilisearchProductTransformer(
|
||||||
|
document: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const metadata = (document.metadata ?? null) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null
|
||||||
|
const categories = document.categories as
|
||||||
|
| Array<{ name?: string | null }>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
const collection = document.collection as { title?: string | null } | null
|
||||||
|
const variants = document.variants as
|
||||||
|
| Array<{
|
||||||
|
inventory_quantity?: number | null
|
||||||
|
manage_inventory?: boolean | null
|
||||||
|
allow_backorder?: boolean | null
|
||||||
|
prices?: Array<{
|
||||||
|
amount?: number | null
|
||||||
|
currency_code?: string | null
|
||||||
|
}> | null
|
||||||
|
}>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
const variant = variants?.[0]
|
||||||
|
const tags = document.tags as Array<{ value?: string | null }> | null
|
||||||
|
|
||||||
|
const category =
|
||||||
|
categories?.[0]?.name || stringMetadata(metadata, "category") || "Variedades"
|
||||||
|
|
||||||
|
const available =
|
||||||
|
!variant ||
|
||||||
|
variant.allow_backorder === true ||
|
||||||
|
variant.manage_inventory === false ||
|
||||||
|
(variant.inventory_quantity ?? 0) > 0
|
||||||
|
|
||||||
|
// variants.calculated_price requiere currency_code/region_id en el
|
||||||
|
// contexto de pricing, que el workflow de sync interno del plugin no
|
||||||
|
// provee -- hace fallar TODO el job de indexado (confirmado en vivo:
|
||||||
|
// "Method calculatePrices requires currency_code in the pricing
|
||||||
|
// context"). variants.prices es la lista cruda de precios (sin
|
||||||
|
// resolver promociones/reglas), no necesita contexto -- alcanza para
|
||||||
|
// el filtro/orden por precio en la busqueda, que no necesita el
|
||||||
|
// precio final promocional.
|
||||||
|
const copPrice = variant?.prices?.find(
|
||||||
|
(price) => price.currency_code?.toLowerCase() === "cop",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: document.id,
|
||||||
|
name: document.title || "Producto ARI Shopping",
|
||||||
|
shortDescription: document.subtitle || document.title || "",
|
||||||
|
description: document.description || "",
|
||||||
|
category,
|
||||||
|
collection: collection?.title || stringMetadata(metadata, "collection") || "",
|
||||||
|
brand: stringMetadata(metadata, "brand") || "ARI Shopping",
|
||||||
|
ageRange: stringMetadata(metadata, "ageRange") || "Todas",
|
||||||
|
tags: (tags || []).map((tag) => tag.value).filter(Boolean),
|
||||||
|
price: typeof copPrice?.amount === "number" ? copPrice.amount : null,
|
||||||
|
available,
|
||||||
|
isNew: booleanMetadata(metadata, "isNew"),
|
||||||
|
featured: booleanMetadata(metadata, "featured"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
projectConfig: {
|
projectConfig: {
|
||||||
databaseUrl: required("DATABASE_URL"),
|
databaseUrl: required("DATABASE_URL"),
|
||||||
@@ -48,6 +133,45 @@ module.exports = defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
|
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
|
||||||
|
// un modulo de fulfillment con el provider manual_manual
|
||||||
|
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
|
||||||
|
// directo) -- pero como este bloque propio comparte el mismo tipo de
|
||||||
|
// modulo, transformModules() lo pisa por completo (indexa por
|
||||||
|
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
|
||||||
|
// manual_manual va explicito ACA tambien: si se omitiera, la
|
||||||
|
// shipping option existente que ya usa ese provider se rompe en
|
||||||
|
// silencio. envioclick-fulfillment implementa el mismo contrato
|
||||||
|
// (AbstractFulfillmentProviderService) contra
|
||||||
|
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
|
||||||
|
// con una llamada de prueba real que devolvio tarifas reales de
|
||||||
|
// varias transportadoras.
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/fulfillment-manual",
|
||||||
|
id: "manual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/envioclick-fulfillment",
|
||||||
|
id: "standard",
|
||||||
|
options: {
|
||||||
|
apiKey: required("ENVIOCLICK_API_KEY"),
|
||||||
|
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
|
||||||
|
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "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: {
|
||||||
@@ -72,5 +196,130 @@ 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
|
||||||
|
// es un servicio nuevo y todavia no probado en produccion en un host
|
||||||
|
// con historial de swap saturado -- si esta caido o mal configurado,
|
||||||
|
// el plugin se degrada solo (soporta enabled:false, "silently skipped"
|
||||||
|
// segun su propio README) en vez de tumbar el arranque de Medusa
|
||||||
|
// entero, que es lo que si haria required().
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
resolve: "@rokmohar/medusa-plugin-meilisearch",
|
||||||
|
options: {
|
||||||
|
config: {
|
||||||
|
host: process.env.MEILISEARCH_HOST ?? "",
|
||||||
|
apiKey: process.env.MEILISEARCH_API_KEY ?? "",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
products: {
|
||||||
|
type: "products",
|
||||||
|
enabled: Boolean(process.env.MEILISEARCH_HOST),
|
||||||
|
primaryKey: "id",
|
||||||
|
// Campos que el plugin pide via query.graph antes de pasarlos
|
||||||
|
// al transformer de arriba -- si falta alguno acá, llega
|
||||||
|
// undefined al transformer sin importar lo que este haga.
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"description",
|
||||||
|
"handle",
|
||||||
|
"status",
|
||||||
|
"thumbnail",
|
||||||
|
"metadata",
|
||||||
|
"tags.value",
|
||||||
|
"categories.name",
|
||||||
|
"collection.title",
|
||||||
|
"variants.inventory_quantity",
|
||||||
|
"variants.manage_inventory",
|
||||||
|
"variants.allow_backorder",
|
||||||
|
"variants.prices.amount",
|
||||||
|
"variants.prices.currency_code",
|
||||||
|
],
|
||||||
|
indexSettings: {
|
||||||
|
// Tabla de ADR 0006 §3: Buscable / Filtrable-faceta / Ordenable.
|
||||||
|
searchableAttributes: [
|
||||||
|
"name",
|
||||||
|
"shortDescription",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"collection",
|
||||||
|
"brand",
|
||||||
|
"tags",
|
||||||
|
],
|
||||||
|
filterableAttributes: [
|
||||||
|
"category",
|
||||||
|
"collection",
|
||||||
|
"brand",
|
||||||
|
"ageRange",
|
||||||
|
"price",
|
||||||
|
"available",
|
||||||
|
],
|
||||||
|
sortableAttributes: ["price", "isNew", "featured"],
|
||||||
|
},
|
||||||
|
transformer: meilisearchProductTransformer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
+242
-1
@@ -12,7 +12,9 @@
|
|||||||
"@medusajs/admin-sdk": "2.18.0",
|
"@medusajs/admin-sdk": "2.18.0",
|
||||||
"@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",
|
||||||
|
"epayco-sdk-node": "^1.4.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@swc/core": "^1.7.28",
|
"@swc/core": "^1.7.28",
|
||||||
@@ -6561,6 +6563,28 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rokmohar/medusa-plugin-meilisearch": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rokmohar/medusa-plugin-meilisearch/-/medusa-plugin-meilisearch-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-+iStv5sc2vuLk+O7OcQLlNR99fMBhGdBOJMCmDJYoTFGhzBA4ZEV18IIr2xqxUDgdQo1haF0TtYv3JmRQUuY1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"meilisearch": "^0.56.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@medusajs/admin-sdk": "^2.15.2",
|
||||||
|
"@medusajs/framework": "^2.15.2",
|
||||||
|
"@medusajs/medusa": "^2.15.2",
|
||||||
|
"@medusajs/ui": "^4.1.12",
|
||||||
|
"@tanstack/react-query": "5.64.2",
|
||||||
|
"awilix": "^8.0.1",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -9177,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",
|
||||||
@@ -9340,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",
|
||||||
@@ -9349,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",
|
||||||
@@ -9411,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",
|
||||||
@@ -9783,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",
|
||||||
@@ -10076,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",
|
||||||
@@ -10269,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",
|
||||||
@@ -11184,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",
|
||||||
@@ -11774,6 +11877,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meilisearch": {
|
||||||
|
"version": "0.56.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.56.0.tgz",
|
||||||
|
"integrity": "sha512-kBXba8DcSrLWHYqopCm2JL90oBy97VdIfHkP1ii7/eHufeEEEk9Zu5Vv/IFQpNzmhEpWszVFVlGvZoI6QJeGzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
@@ -12135,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",
|
||||||
@@ -12895,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",
|
||||||
@@ -14325,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",
|
||||||
@@ -14606,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",
|
||||||
@@ -15070,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",
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"@medusajs/admin-sdk": "2.18.0",
|
"@medusajs/admin-sdk": "2.18.0",
|
||||||
"@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",
|
||||||
|
"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,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,355 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 accion","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) => [normalizeTitleForMatch(c.name), c.id]),
|
||||||
|
);
|
||||||
|
const collectionByName = new Map(
|
||||||
|
collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(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(normalizeTitleForMatch(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(normalizeTitleForMatch(categoryName), categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionName = row.coleccion?.trim();
|
||||||
|
let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(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(normalizeTitleForMatch(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,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,197 @@
|
|||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
QueryContext,
|
||||||
|
} from "@medusajs/framework/utils";
|
||||||
|
import {
|
||||||
|
MEILISEARCH_MODULE,
|
||||||
|
type MeiliSearchService,
|
||||||
|
} from "@rokmohar/medusa-plugin-meilisearch";
|
||||||
|
|
||||||
|
// Las rutas /store/meilisearch/* que trae el plugin (ver ADR 0006 nota de
|
||||||
|
// implementacion) no devuelven facetDistribution -- necesitamos esta
|
||||||
|
// ruta propia para pedirlo explicitamente via additionalOptions.facets,
|
||||||
|
// que el servicio del plugin reenvia tal cual al cliente Meilisearch.
|
||||||
|
const FACETS = ["category", "collection", "brand", "ageRange"];
|
||||||
|
|
||||||
|
// Mismos campos que ya usa el storefront contra /store/products
|
||||||
|
// (workloads/ecommerce/lib/headless/providers/medusa.ts) -- una vez que
|
||||||
|
// Meilisearch nos da los ids candidatos, volvemos a Medusa via
|
||||||
|
// query.graph para precio/inventario reales, nunca confiamos en el
|
||||||
|
// valor indexado (puede tener unos segundos de retraso respecto al
|
||||||
|
// evento de sincronizacion).
|
||||||
|
const PRODUCT_FIELDS = [
|
||||||
|
"id",
|
||||||
|
"handle",
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"description",
|
||||||
|
"thumbnail",
|
||||||
|
"external_id",
|
||||||
|
"metadata",
|
||||||
|
"images.url",
|
||||||
|
"tags.value",
|
||||||
|
"categories.name",
|
||||||
|
"collection.title",
|
||||||
|
"variants.id",
|
||||||
|
"variants.title",
|
||||||
|
"variants.sku",
|
||||||
|
"variants.manage_inventory",
|
||||||
|
"variants.allow_backorder",
|
||||||
|
"variants.inventory_quantity",
|
||||||
|
"variants.calculated_price.calculated_amount",
|
||||||
|
"variants.calculated_price.original_amount",
|
||||||
|
"variants.calculated_price.currency_code",
|
||||||
|
"variants.options.value",
|
||||||
|
"variants.options.option.title",
|
||||||
|
];
|
||||||
|
|
||||||
|
function stringParam(
|
||||||
|
query: MedusaRequest["query"],
|
||||||
|
name: string,
|
||||||
|
): string | undefined {
|
||||||
|
const value = query[name];
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberParam(
|
||||||
|
query: MedusaRequest["query"],
|
||||||
|
name: string,
|
||||||
|
): number | undefined {
|
||||||
|
const value = stringParam(query, name);
|
||||||
|
if (!value) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meilisearch usa comillas dobles para valores de filtro con espacios;
|
||||||
|
// escapamos las que vengan en el propio valor para no romper la sintaxis.
|
||||||
|
function escapeFilterValue(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const meilisearchService = req.scope.resolve<MeiliSearchService>(
|
||||||
|
MEILISEARCH_MODULE,
|
||||||
|
);
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
||||||
|
|
||||||
|
const q = stringParam(req.query, "q") ?? "";
|
||||||
|
const limit = numberParam(req.query, "limit") ?? 24;
|
||||||
|
const offset = numberParam(req.query, "offset") ?? 0;
|
||||||
|
// calculated_price exige region_id/currency_code en el contexto de
|
||||||
|
// pricing o query.graph tira "Method calculatePrices requires
|
||||||
|
// currency_code in the pricing context" (mismo error que ya rompio el
|
||||||
|
// job de sync -- ver medusa-config.ts). El storefront ya manda
|
||||||
|
// region_id contra /store/products (medusa.ts, regionId()); esta ruta
|
||||||
|
// espera el mismo query param.
|
||||||
|
const regionId = stringParam(req.query, "region_id");
|
||||||
|
|
||||||
|
const filterParts: string[] = [];
|
||||||
|
for (const facet of ["category", "collection", "brand", "ageRange"]) {
|
||||||
|
const value = stringParam(req.query, facet);
|
||||||
|
if (value) {
|
||||||
|
filterParts.push(`${facet} = "${escapeFilterValue(value)}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const minPrice = numberParam(req.query, "minPrice");
|
||||||
|
const maxPrice = numberParam(req.query, "maxPrice");
|
||||||
|
if (typeof minPrice === "number") filterParts.push(`price >= ${minPrice}`);
|
||||||
|
if (typeof maxPrice === "number") filterParts.push(`price <= ${maxPrice}`);
|
||||||
|
|
||||||
|
const indexes = await meilisearchService.getIndexesByType("products");
|
||||||
|
|
||||||
|
if (indexes.length === 0) {
|
||||||
|
// Meilisearch no configurado o el indice "products" esta
|
||||||
|
// deshabilitado -- degradar a "sin resultados" en vez de 500, el
|
||||||
|
// frontend cae de nuevo a su propio filtrado si esta ruta falla.
|
||||||
|
res.json({ products: [], facetDistribution: {}, count: 0, limit, offset });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
indexes.map((indexKey) =>
|
||||||
|
meilisearchService.search(indexKey, q, {
|
||||||
|
filter: filterParts.length ? filterParts.join(" AND ") : undefined,
|
||||||
|
paginationOptions: { limit, offset },
|
||||||
|
additionalOptions: { facets: FACETS },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hits = results.flatMap((result) => result.hits);
|
||||||
|
const totalCount = results.reduce(
|
||||||
|
(sum, result) => sum + result.estimatedTotalHits,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const facetDistribution: Record<string, Record<string, number>> = {};
|
||||||
|
for (const result of results) {
|
||||||
|
const resultFacets = (
|
||||||
|
result as unknown as {
|
||||||
|
facetDistribution?: Record<string, Record<string, number>>;
|
||||||
|
}
|
||||||
|
).facetDistribution;
|
||||||
|
if (!resultFacets) continue;
|
||||||
|
for (const [facet, values] of Object.entries(resultFacets)) {
|
||||||
|
facetDistribution[facet] ??= {};
|
||||||
|
for (const [value, count] of Object.entries(values)) {
|
||||||
|
facetDistribution[facet][value] =
|
||||||
|
(facetDistribution[facet][value] ?? 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = hits.map((hit) => (hit as { id: string }).id);
|
||||||
|
|
||||||
|
if (productIds.length === 0) {
|
||||||
|
res.json({ products: [], facetDistribution, count: 0, limit, offset });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculatePrices exige currency_code en el contexto -- no alcanza con
|
||||||
|
// region_id solo (a diferencia de /store/products nativo, que lo
|
||||||
|
// resuelve el middleware setPricingContext; esta ruta no pasa por esa
|
||||||
|
// cadena). Resolvemos la moneda real de la region en vez de asumir COP
|
||||||
|
// fijo, por si el catalogo suma otra region/moneda mas adelante.
|
||||||
|
let currencyCode: string | undefined;
|
||||||
|
if (regionId) {
|
||||||
|
const { data: regions = [] } = await query.graph({
|
||||||
|
entity: "region",
|
||||||
|
fields: ["id", "currency_code"],
|
||||||
|
filters: { id: regionId },
|
||||||
|
});
|
||||||
|
currencyCode = regions[0]?.currency_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: products = [] } = await query.graph({
|
||||||
|
entity: "product",
|
||||||
|
fields: PRODUCT_FIELDS,
|
||||||
|
filters: { id: productIds },
|
||||||
|
context:
|
||||||
|
regionId && currencyCode
|
||||||
|
? {
|
||||||
|
variants: {
|
||||||
|
calculated_price: QueryContext({
|
||||||
|
region_id: regionId,
|
||||||
|
currency_code: currencyCode,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preserva el ranking de relevancia de Meilisearch -- query.graph no
|
||||||
|
// garantiza el mismo orden que la busqueda.
|
||||||
|
const orderedProducts = [...products].sort(
|
||||||
|
(a, b) => productIds.indexOf(a.id) - productIds.indexOf(b.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
products: orderedProducts,
|
||||||
|
facetDistribution,
|
||||||
|
count: totalCount,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
# Notas sueltas / aprendizajes
|
# Notas sueltas / aprendizajes
|
||||||
|
|
||||||
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
|
||||||
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
> con notas cortas a medida que aparecen. Cada entrada está extraída de
|
||||||
> entradas de abajo son semillas reales extraídas de incidentes ya
|
> un incidente o hallazgo real ya documentado en otra parte del sitio,
|
||||||
> documentados; expandir cada una con más contexto en fases siguientes.
|
> no es especulación.
|
||||||
|
|
||||||
## Qué haría distinto
|
|
||||||
|
|
||||||
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
|
|
||||||
después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
|
|
||||||
predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
|
||||||
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
|
||||||
init containers distintos, y que el chequeo use el mínimo de
|
|
||||||
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
|
||||||
de conexión pensada para la app. Confirmado como decisión correcta en
|
|
||||||
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
|
||||||
|
|
||||||
## Gotchas de Medusa v2
|
## Gotchas de Medusa v2
|
||||||
|
|
||||||
@@ -26,8 +15,136 @@
|
|||||||
del array `images` — hay que setearlo explícito desde el Admin si el
|
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||||
frontend lo usa como imagen hero en listados.
|
frontend lo usa como imagen hero en listados.
|
||||||
|
|
||||||
## TODO
|
## MinIO / S3 provider
|
||||||
|
|
||||||
- Notas sobre MinIO / S3 provider.
|
- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
|
||||||
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
`ari-products`) usando el provider S3-compatible estándar — MinIO no
|
||||||
- Qué automatizaría si volviera a montar este lab desde cero.
|
sabe que no es AWS real, y Medusa tampoco necesita saberlo.
|
||||||
|
- La URL pública que termina viendo el navegador **no** es la interna
|
||||||
|
(`minio-svc:9000`): es `S3_FILE_URL=https://media.cruzcloud.net/ari-products`,
|
||||||
|
un dominio propio proxied por NPM hacia el mismo MinIO. Esto tiene
|
||||||
|
una consecuencia directa que ya rompió producción una vez: si
|
||||||
|
`media.cruzcloud.net` deja de rutear bien (mismo patrón de
|
||||||
|
`forward_host` roto que el resto del sitio), **todas** las imágenes
|
||||||
|
de producto del storefront se caen aunque MinIO y Medusa estén
|
||||||
|
perfectamente sanos — porque el navegador nunca habla con MinIO
|
||||||
|
directo, solo con esa URL pública. Ver [Red y exposición](../arquitectura/red-y-exposicion.md).
|
||||||
|
|
||||||
|
## Subscriber `revalidate-storefront.ts` y sus límites
|
||||||
|
|
||||||
|
- Escucha 6 eventos de Medusa (`product.created/updated/deleted` +
|
||||||
|
los 3 equivalentes de `product-variant`) y por cada uno hace un
|
||||||
|
único `POST` a `${STOREFRONT_URL}/api/revalidate` con un header
|
||||||
|
secreto (`x-revalidate-secret`) — esa ruta del frontend llama
|
||||||
|
`revalidateTag("medusa-products")` de Next.js.
|
||||||
|
- **Se degrada solo, no tira el evento entero**: si
|
||||||
|
`STOREFRONT_URL`/`REVALIDATE_SECRET` no están configurados, o el
|
||||||
|
`fetch` falla, solo loguea un `warn` y sigue — un fallo de red hacia
|
||||||
|
el frontend nunca revierte ni bloquea la operación real en Medusa
|
||||||
|
(crear/editar un producto). Mismo criterio de "degradar, no romper"
|
||||||
|
que se repite en el resto del storefront (`searchWithFacets`
|
||||||
|
opcional, `/api/search` cayendo a `{}` si Meilisearch no responde).
|
||||||
|
- **Límite real, todavía no un problema**: no hay debounce ni
|
||||||
|
deduplicación — si una operación dispara varios eventos juntos (ej.
|
||||||
|
editar un producto con 3 variantes a la vez), son varios `POST`
|
||||||
|
independientes al mismo endpoint. `revalidateTag` de Next.js es
|
||||||
|
barato e idempotente, así que hoy no se nota, pero si el catálogo
|
||||||
|
creciera mucho y las ediciones masivas se volvieran frecuentes, sería
|
||||||
|
el primer lugar donde agregar algún tipo de agrupación.
|
||||||
|
|
||||||
|
## Qué haría distinto si volviera a montar este lab desde cero
|
||||||
|
|
||||||
|
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno** (ver
|
||||||
|
[ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)) y
|
||||||
|
**ajustar el MTU de `flannel.1` desde el primer `deploy-lab.sh`** —
|
||||||
|
ambos terminaron siendo incidentes reales
|
||||||
|
(504 intermitente, blackhole cross-node) que en retrospectiva eran
|
||||||
|
predecibles: red doméstica con NAT agresivo + Docker-in-Docker
|
||||||
|
multi-nodo son una combinación conocida para tener problemas de MTU
|
||||||
|
y de transporte UDP. Hoy los dos ya están automatizados
|
||||||
|
(`fix_flannel_mtu` en `deploy-lab.sh`, la variable de entorno del
|
||||||
|
túnel), pero se aprendieron reactivamente, no se anticiparon.
|
||||||
|
- **Usar `git merge-base --is-ancestor` para la promoción segura del
|
||||||
|
CI desde el primer pipeline**, no después de ver la condición de
|
||||||
|
carrera en vivo (dos pipelines corriendo en serie sobre el mismo
|
||||||
|
commit, el segundo pisando o saltándose la promoción del primero).
|
||||||
|
El patrón correcto no es complicado una vez que se conoce — el costo
|
||||||
|
fue no anticiparlo.
|
||||||
|
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
||||||
|
init containers distintos, y que el chequeo use el mínimo de
|
||||||
|
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
||||||
|
de conexión pensada para la app. Confirmado como decisión correcta en
|
||||||
|
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||||
|
- **No escribir un script que regenere credenciales completas en cada
|
||||||
|
corrida** (`create-commerce-secrets.sh` rota JWT/COOKIE/passwords de
|
||||||
|
Postgres/Redis/MinIO enteros cada vez que se ejecuta, incluso si solo
|
||||||
|
hacía falta agregar una clave nueva) — un script de setup inicial y
|
||||||
|
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
|
||||||
|
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.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Del commit al despliegue: flujo GitOps
|
# Del commit al despliegue: flujo GitOps
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
|
El diagrama de abajo refleja el pipeline real (build → push imagen →
|
||||||
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
|
promoción segura → commit del tag → sync de Argo CD). Después del
|
||||||
> secuencia de abajo refleja el pipeline real (build → push imagen →
|
diagrama hay un ejemplo con hashes de commit reales, tomado de un
|
||||||
> promoción segura → commit del tag → sync de Argo CD).
|
cambio real hecho sobre este mismo repo.
|
||||||
|
|
||||||
## Secuencia: un commit hasta quedar corriendo en el cluster
|
## Secuencia: un commit hasta quedar corriendo en el cluster
|
||||||
|
|
||||||
@@ -42,9 +42,53 @@ sequenceDiagram
|
|||||||
evita que un pipeline viejo sobreescriba el tag con una versión
|
evita que un pipeline viejo sobreescriba el tag con una versión
|
||||||
anterior si hubo un push más reciente mientras corría.
|
anterior si hubo un push más reciente mientras corría.
|
||||||
|
|
||||||
## TODO
|
## Ejemplo real: un commit de principio a fin
|
||||||
|
|
||||||
- Ejemplo real con hashes de commit (tomar del historial de
|
Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
|
||||||
`apps-registry`).
|
`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
|
||||||
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
|
completo dos veces seguidas el mismo día:
|
||||||
pasa si alguien hace `kubectl edit` a mano).
|
|
||||||
|
| Paso | Commit / evento real |
|
||||||
|
|---|---|
|
||||||
|
| Feature | `83d448b` — `feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo`, en `fix/adr0006-etapa2-fase3b-facetas-sidebar` |
|
||||||
|
| Merge a `main` | `6b5450f` |
|
||||||
|
| CI build+push | Gitea Actions task 258, imagen `gitea.cruzcloud.net/devops/ecommerce-frontend` |
|
||||||
|
| Release commit (CD) | `951682a` — `chore: release v1.0.171 [skip ci]`, bump automático del tag en `frontend/patches/deployment.yaml` |
|
||||||
|
| Sync de Argo CD | dos ciclos distintos: primero sincronizó contra `6b5450f` (el merge, todavía con el tag viejo), y en el ciclo siguiente detectó `951682a` y aplicó el tag nuevo |
|
||||||
|
| Rollout | `frontend-deploy` con 2/2 réplicas en `v1.0.171` |
|
||||||
|
|
||||||
|
El gate en vivo contra ese mismo cambio encontró un bug real
|
||||||
|
(`estimatedTotalHits` en 0 con `limit=0` en Meilisearch) — el fix
|
||||||
|
repitió el mismo ciclo completo: `884dbd1` → merge `ba3e6c7` → release
|
||||||
|
`v1.0.172` → sync → rollout. Dos vueltas completas del diagrama de
|
||||||
|
arriba en la misma sesión, cada una verificada contra el endpoint real
|
||||||
|
antes de darla por cerrada.
|
||||||
|
|
||||||
|
**Detalle no obvio:** Argo CD sincroniza el *merge commit* primero
|
||||||
|
(todavía con la imagen vieja, porque el bump de tag es un commit
|
||||||
|
*posterior* que hace el propio pipeline) y recién en un segundo ciclo
|
||||||
|
de poll detecta el commit de release. Son dos sincronizaciones
|
||||||
|
distintas, no una sola — hay que esperar ambas antes de asumir que el
|
||||||
|
pod nuevo está corriendo.
|
||||||
|
|
||||||
|
## `selfHeal`, con la configuración real
|
||||||
|
|
||||||
|
Todas las Applications de este repo comparten el mismo `syncPolicy`
|
||||||
|
(`apps/ecommerce-app.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Con `selfHeal: true`, si alguien corre `kubectl scale deployment
|
||||||
|
frontend-deploy --replicas=1` directo contra el cluster (saltándose
|
||||||
|
Git por completo), Argo CD no se queda en mostrar `OutOfSync` — en el
|
||||||
|
siguiente ciclo de reconciliación vuelve a aplicar el manifiesto tal
|
||||||
|
como está en `main` y el Deployment queda otra vez en las réplicas
|
||||||
|
declaradas ahí. El cambio manual no se pierde silenciosamente: queda
|
||||||
|
un instante como drift visible en la UI de Argo CD, pero nunca se
|
||||||
|
vuelve el estado final. La única forma de que ese cambio persista es
|
||||||
|
que exista en Git — lo mismo que describe [ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md).
|
||||||
@@ -65,9 +65,142 @@ plantillas (a diferencia de Helm).
|
|||||||
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
||||||
templating separado.
|
templating separado.
|
||||||
|
|
||||||
## TODO
|
## Namespace
|
||||||
|
|
||||||
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
|
**En una frase:** una partición lógica dentro del mismo cluster físico
|
||||||
- Ingress vs. Service vs. Deployment
|
— agrupa recursos relacionados y les pone un límite alrededor.
|
||||||
- StatefulSet (por qué Postgres lo usa y el frontend no)
|
|
||||||
- Init container
|
**Detalle:** este lab tiene un namespace por dominio de negocio
|
||||||
|
(`ecommerce`, `docs-portal`) más `argocd` para el propio Argo CD. Todo
|
||||||
|
lo que gobierna a un namespace (cuotas, políticas de red) vive en
|
||||||
|
`platform-infra`, separado de los manifiestos de la app — así una app
|
||||||
|
nueva no puede "olvidarse" de traer su propia gobernanza.
|
||||||
|
|
||||||
|
## ResourceQuota
|
||||||
|
|
||||||
|
**En una frase:** el techo total de recursos que un namespace entero
|
||||||
|
puede consumir, sumando todos sus pods.
|
||||||
|
|
||||||
|
**Detalle:** la cuota real de `ecommerce`
|
||||||
|
(`platform-infra/ecommerce-governance/resourcequota.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hard:
|
||||||
|
requests.cpu: "3"
|
||||||
|
requests.memory: 4Gi
|
||||||
|
limits.cpu: "8"
|
||||||
|
limits.memory: 8Gi
|
||||||
|
pods: "12"
|
||||||
|
persistentvolumeclaims: "6"
|
||||||
|
requests.storage: 80Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
Si un Deployment nuevo pidiera más `requests.memory` del que queda
|
||||||
|
disponible bajo ese techo, Kubernetes rechaza el pod directamente — no
|
||||||
|
lo deja arrancar y luego fallar.
|
||||||
|
|
||||||
|
## LimitRange
|
||||||
|
|
||||||
|
**En una frase:** los valores por defecto (y los mínimos/máximos) que
|
||||||
|
aplican a un contenedor *individual* dentro de un namespace, para
|
||||||
|
cuando el manifiesto no los declara explícitos.
|
||||||
|
|
||||||
|
**Detalle:** complementa a `ResourceQuota` (que mira el namespace
|
||||||
|
entero) a nivel de un solo contenedor. La de `ecommerce`
|
||||||
|
(`limitrange.yaml`) pone `defaultRequest: 100m/128Mi`,
|
||||||
|
`default: 500m/512Mi` (límites si el contenedor no los declara), y un
|
||||||
|
rango permitido de `10m/16Mi` a `2 CPU/1Gi` por contenedor — evita
|
||||||
|
tanto el contenedor que no pide nada y termina compitiendo mal por
|
||||||
|
recursos, como el que pide de más y se come la cuota del namespace
|
||||||
|
entero él solo.
|
||||||
|
|
||||||
|
## NetworkPolicy
|
||||||
|
|
||||||
|
**En una frase:** un firewall a nivel de Kubernetes — qué tráfico de
|
||||||
|
red se permite entrar (o salir) de un pod.
|
||||||
|
|
||||||
|
**Detalle:** sin ninguna `NetworkPolicy`, todos los pods de un cluster
|
||||||
|
pueden hablarse entre sí libremente. La de `ecommerce`
|
||||||
|
(`networkpolicy.yaml`) usa `podSelector: {}` (aplica a todos los pods
|
||||||
|
del namespace) y permite ingress solo desde `namespaceSelector: {}`
|
||||||
|
(cualquier namespace del cluster, típicamente Traefik llegando desde
|
||||||
|
`kube-system` o el namespace de ingress) — bloquea tráfico externo al
|
||||||
|
cluster que intentara saltarse Traefik y pegarle directo a un Service
|
||||||
|
interno.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
**En una frase:** el controlador para cargas de trabajo **sin
|
||||||
|
estado** — réplicas intercambiables entre sí, ninguna con identidad
|
||||||
|
propia.
|
||||||
|
|
||||||
|
**Detalle:** `frontend-deploy` es un Deployment de 2 réplicas: no
|
||||||
|
importa cuál de las dos atienda una petición, ambas sirven exactamente
|
||||||
|
lo mismo y ninguna guarda datos propios en disco. Si un pod muere,
|
||||||
|
Kubernetes crea otro cualquiera para reemplazarlo — no hay "el pod
|
||||||
|
original" que recuperar. Contrastar con **StatefulSet** abajo.
|
||||||
|
|
||||||
|
## Service
|
||||||
|
|
||||||
|
**En una frase:** un nombre de red estable que apunta a un grupo de
|
||||||
|
pods, aunque esos pods cambien.
|
||||||
|
|
||||||
|
**Detalle:** los pods son efímeros (mueren y se recrean con IP nueva
|
||||||
|
cada vez); un Service (ej. `frontend-svc`, `postgres-svc`) les da un
|
||||||
|
nombre DNS interno fijo (`frontend-svc.ecommerce.svc.cluster.local`,
|
||||||
|
o simplemente `frontend-svc` desde dentro del mismo namespace) que no
|
||||||
|
cambia aunque los pods de atrás sí. `postgres-svc` en este lab es
|
||||||
|
además un Service `clusterIP: None` ("headless") — necesario porque
|
||||||
|
`commerce-postgres` es un StatefulSet y cada pod necesita su propia
|
||||||
|
identidad de red estable, no una IP compartida balanceada entre
|
||||||
|
réplicas.
|
||||||
|
|
||||||
|
## Ingress
|
||||||
|
|
||||||
|
**En una frase:** la regla que dice "las peticiones a este dominio y
|
||||||
|
esta ruta van a este Service", para tráfico HTTP que entra al cluster.
|
||||||
|
|
||||||
|
**Detalle:** `ecommerce-ingress` enruta por *path*, no solo por
|
||||||
|
dominio: `shop.cruzcloud.net/api/catalog` va a `catalog-api-svc`, y
|
||||||
|
todo lo demás (`/`) va a `frontend-svc` — un solo objeto Ingress con
|
||||||
|
dos backends distintos según el prefijo de la URL. Es Traefik (el
|
||||||
|
Ingress Controller del cluster) quien lee estos objetos y hace el
|
||||||
|
enrutamiento real; el Ingress en sí es solo la declaración de la
|
||||||
|
regla, no el componente que la ejecuta.
|
||||||
|
|
||||||
|
## StatefulSet
|
||||||
|
|
||||||
|
**En una frase:** el controlador para cargas de trabajo **con
|
||||||
|
estado** — cada réplica tiene identidad propia y almacenamiento propio
|
||||||
|
que sobrevive a que el pod se reinicie.
|
||||||
|
|
||||||
|
**Detalle:** `commerce-postgres` es un StatefulSet, no un Deployment,
|
||||||
|
porque el frontend puede tener 2 réplicas idénticas sin datos propios,
|
||||||
|
pero Postgres no — sus datos tienen que vivir en un disco específico
|
||||||
|
que sobreviva un reinicio del pod y sepa exactamente cuál pod es
|
||||||
|
"el suyo". Cada pod de un StatefulSet obtiene su propio
|
||||||
|
`PersistentVolumeClaim` vía `volumeClaimTemplates` (acá,
|
||||||
|
`postgres-data`, 10Gi en `local-path`) y un nombre de red estable y
|
||||||
|
numerado (`commerce-postgres-0`), a diferencia de los nombres
|
||||||
|
aleatorios de un Deployment. Meilisearch, Redis y MinIO en este lab
|
||||||
|
siguen el mismo patrón por la misma razón — todos guardan datos en
|
||||||
|
disco que tienen que sobrevivir al pod.
|
||||||
|
|
||||||
|
## Init container
|
||||||
|
|
||||||
|
**En una frase:** un contenedor que corre **antes** que los
|
||||||
|
contenedores principales de un pod, y tiene que terminar bien (exit
|
||||||
|
0) para que el resto arranque.
|
||||||
|
|
||||||
|
**Detalle:** `medusa-deploy` usa dos, y a propósito están separados en
|
||||||
|
vez de ser uno solo: `wait-for-postgres` (`pg_isready` en loop, solo
|
||||||
|
espera a que Postgres acepte conexiones) y `migrations`
|
||||||
|
(`db:migrate`, corre las migraciones de verdad). Un incidente real
|
||||||
|
mostró por qué separarlos importa: cuando el chequeo de disponibilidad
|
||||||
|
reusaba la misma `DATABASE_URL` completa de la app (con
|
||||||
|
`?sslmode=disable` incluido), el parser de `pg_isready` no la entendía
|
||||||
|
y el pod quedaba atascado en `Init` — reportado como si fuera
|
||||||
|
`CrashLoopBackOff`, sin serlo. El fix fue que el chequeo de
|
||||||
|
disponibilidad use el mínimo de parámetros posible
|
||||||
|
(host/puerto/usuario/db), nunca la URI completa pensada para la app.
|
||||||
|
Ver el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# Red y exposición
|
# Red y exposición
|
||||||
|
|
||||||
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
|
Los 5 dominios de abajo son reales y comparten exactamente el mismo
|
||||||
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
|
camino de red — ninguno tiene un caso especial.
|
||||||
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
|
|
||||||
|
|
||||||
## Dominios activos
|
## Dominios activos
|
||||||
|
|
||||||
@@ -14,12 +13,63 @@
|
|||||||
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
||||||
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
|
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
|
||||||
|
|
||||||
## TODO
|
## Por qué todos van "proxied" (naranja), ninguno "DNS-only"
|
||||||
|
|
||||||
- Diagrama de TLS termination (dónde termina TLS realmente: en
|
Un registro DNS-only en Cloudflare expondría directamente la IP real
|
||||||
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
|
del túnel — pero acá no hay IP pública fija que exponer: el NAS está
|
||||||
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
|
detrás de NAT residencial. El modo proxied es lo que hace que
|
||||||
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
|
||||||
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
|
el edge de Cloudflare, nunca directo al origen), así que no hay ningún
|
||||||
- Patrón de diagnóstico "interno sano, público roto" (ver
|
dominio de este lab para el que DNS-only sea una opción real.
|
||||||
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
|
|
||||||
|
## Dónde termina TLS de verdad
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge<br/>🔒 TLS termina acá"]
|
||||||
|
CF -->|túnel cifrado, HTTP/2| CFD["cloudflared"]
|
||||||
|
CFD -->|HTTP plano| NPM["Nginx Proxy Manager"]
|
||||||
|
NPM -->|HTTP plano| LB["k3d-proxy :90"]
|
||||||
|
LB -->|HTTP plano| TR["Traefik"]
|
||||||
|
TR -->|HTTP plano| SVC["Service interno"]
|
||||||
|
```
|
||||||
|
|
||||||
|
TLS termina en el **edge de Cloudflare**, no en Traefik ni en NPM. De
|
||||||
|
ahí hacia adentro (`cloudflared` → NPM → `k3d-proxy` → Traefik →
|
||||||
|
Service) todo el tráfico va en HTTP plano — seguro en este contexto
|
||||||
|
porque ese tramo entero vive dentro de la red local del NAS, nunca
|
||||||
|
sale a internet. Ningún componente interno necesita certificado propio
|
||||||
|
ni gestionar renovación de TLS.
|
||||||
|
|
||||||
|
## Por qué HTTP/2 y no QUIC en el túnel
|
||||||
|
|
||||||
|
`cloudflared` puede usar QUIC (UDP) o HTTP/2 (TCP) para sus conexiones
|
||||||
|
salientes hacia el edge. Este lab fuerza HTTP/2 explícitamente — no es
|
||||||
|
el default. Un incidente real de `504 Gateway Timeout` intermitente en
|
||||||
|
**todos** los subdominios (no solo uno) se rastreó hasta fallos
|
||||||
|
recurrentes del "control stream" de QUIC sobre la red doméstica del
|
||||||
|
NAS. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
||||||
|
para la decisión completa y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)
|
||||||
|
para el diagnóstico paso a paso.
|
||||||
|
|
||||||
|
## Patrón de diagnóstico: "interno sano, público roto"
|
||||||
|
|
||||||
|
Cuando un dominio público falla pero todo dentro del cluster responde
|
||||||
|
bien (pods `Running`, probes verdes, `kubectl exec` con `curl` al
|
||||||
|
Service funciona), la causa casi nunca está en Kubernetes — está en la
|
||||||
|
capa NPM. La forma rápida de confirmarlo es saltarse NPM a propósito:
|
||||||
|
`curl -H "Host: <dominio>" http://192.168.68.61:90/` habla directo con
|
||||||
|
`k3d-proxy`/Traefik sin pasar por NPM. Si eso funciona pero el dominio
|
||||||
|
público no, el problema está específicamente en el `forward_host`/
|
||||||
|
`forward_port` del proxy host en NPM (ej.: un scheme o un path colado
|
||||||
|
en `forward_host`, que rompe todo el enrutamiento).
|
||||||
|
|
||||||
|
Este patrón se repitió dos veces en este lab con la misma causa raíz
|
||||||
|
exacta: el [incidente de imágenes en `media.cruzcloud.net`](../playbooks/incidente-imagenes-npm.md)
|
||||||
|
(playbook completo) y un incidente posterior de `commerce.cruzcloud.net`
|
||||||
|
devolviendo 400 por el mismo tipo de `forward_host` roto (sin playbook
|
||||||
|
propio todavía). Una lección adicional de ese segundo incidente:
|
||||||
|
editar el `proxy_host` a mano en el sqlite de NPM **no** regenera el
|
||||||
|
`.conf` de nginx automáticamente — el cambio tiene que pasar por la
|
||||||
|
propia UI/API de NPM, o el `.conf` hay que parchearlo a mano y
|
||||||
|
recargar nginx.
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# Sesión persistente de Claude Code (tmux en Docker)
|
||||||
|
|
||||||
|
## Problema que resuelve
|
||||||
|
|
||||||
|
Correr Claude Code directo en una shell SSH sobre el NAS lo ata al
|
||||||
|
ciclo de vida de esa conexión: un corte de red, cambiar de laptop a
|
||||||
|
tablet, o cerrar la terminal por accidente mata el proceso y con él
|
||||||
|
todo el contexto de trabajo en curso — no hay forma de "retomar
|
||||||
|
donde quedó" porque el proceso ya no existe.
|
||||||
|
|
||||||
|
**Esto es un problema distinto al de los límites de uso/cuota de
|
||||||
|
Claude Code.** Este setup no aumenta cuota de mensajes ni tokens —
|
||||||
|
eso lo controla el backend de Anthropic sin importar dónde corra el
|
||||||
|
CLI. Lo único que resuelve es que la *sesión de terminal* sobreviva
|
||||||
|
a cortes de red o cambios de dispositivo; si la cuota se agota, sigue
|
||||||
|
agotada exactamente igual dentro de este setup.
|
||||||
|
|
||||||
|
## Por qué Docker y no instalación nativa
|
||||||
|
|
||||||
|
ZimaOS no tiene gestor de paquetes tradicional utilizable para esto:
|
||||||
|
`/` está montado como `squashfs` de solo lectura (imagen del SO
|
||||||
|
inmutable), y `/usr` es un overlay de solo lectura sobre eso —
|
||||||
|
`/usr/local/bin` ni siquiera existe y no se puede crear, tampoco como
|
||||||
|
root (`mkdir` falla con "Read-only file system"). No hay dónde
|
||||||
|
instalar Node.js, npm ni tmux directamente sobre el host.
|
||||||
|
|
||||||
|
Un contenedor Docker sortea esto por completo: tiene su propio
|
||||||
|
filesystem de escritura (overlay2), independiente del `squashfs` del
|
||||||
|
host — ahí es donde `npm install -g` deja el binario de `claude` sin
|
||||||
|
tocar el host en absoluto. Lo único que el contenedor toma del host
|
||||||
|
es lo que se monta explícitamente como volumen.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
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'"]
|
||||||
|
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| 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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
El contenedor sigue vivo aunque la conexión SSH se corte — es el
|
||||||
|
servidor tmux, no la shell del cliente, el que sostiene el proceso de
|
||||||
|
Claude Code. `--restart unless-stopped` además lo revive si el NAS
|
||||||
|
reinicia.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
docker build -t tmux-host:local scripts/tmux-host
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name tmux-host \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /DATA:/DATA \
|
||||||
|
-v /media/HDD-Storage:/media/HDD-Storage \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
|
||||||
|
-v /DATA/AppData/k3d-lab-v3/claude-code-home.json:/root/.claude.json \
|
||||||
|
--network host \
|
||||||
|
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é |
|
||||||
|
|---|---|---|
|
||||||
|
| `/DATA` | `/DATA` | Acceso a los repos (`apps-registry`, `platform-infra`, `scripts`) y configs del lab, mismos paths que en el host |
|
||||||
|
| `/media/HDD-Storage` | `/media/HDD-Storage` | Ver "Gotcha: el symlink `/DATA/AppData`" abajo — sin esto, los repos no se ven dentro del contenedor pese a montar `/DATA` |
|
||||||
|
| `/var/run/docker.sock` | `/var/run/docker.sock` | Seguir controlando Docker y k3d (contenedores, `k3d node`, etc.) desde dentro del contenedor |
|
||||||
|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` | `/root/.claude` | Persistir login/config de Claude Code — sobrevive no solo a un `docker restart`, sino a recrear el contenedor entero |
|
||||||
|
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` | `/root/.claude.json` | Ver "Gotcha: la confianza del folder no persistía" abajo — es un archivo suelto, no vive dentro de `~/.claude/` |
|
||||||
|
|
||||||
|
## Gotcha: el symlink `/DATA/AppData`
|
||||||
|
|
||||||
|
Montar `-v /DATA:/DATA` **no alcanza** para ver los repos dentro del
|
||||||
|
contenedor. `/DATA/AppData` en el host es un symlink *absoluto*:
|
||||||
|
|
||||||
|
```
|
||||||
|
/DATA/AppData -> /media/HDD-Storage/AppData
|
||||||
|
```
|
||||||
|
|
||||||
|
Los repos del lab (`lab-cruzcloud`) viven físicamente en el disco
|
||||||
|
btrfs `/media/HDD-Storage`, no en el ext4 de `/DATA` — ZimaOS lo
|
||||||
|
expone dentro de `/DATA` como submount (`/DATA/.media/HDD-Storage/...`,
|
||||||
|
que sí viaja al contenedor porque Docker hace bind recursivo por
|
||||||
|
defecto) pero el path "bonito" `/DATA/AppData/...` que usan `CLAUDE.md`
|
||||||
|
y todos los scripts del lab llega ahí vía ese symlink absoluto, no vía
|
||||||
|
el submount. Un symlink absoluto que apunta fuera del árbol montado
|
||||||
|
queda roto dentro del contenedor aunque el submount subyacente esté
|
||||||
|
perfectamente accesible por otra ruta.
|
||||||
|
|
||||||
|
**Cómo se detectó:** todo el trabajo de configuración inicial de este
|
||||||
|
mismo setup se hizo con `git`/`docker exec` corriendo *en el host*, no
|
||||||
|
dentro del contenedor — nadie había probado `cd
|
||||||
|
/DATA/AppData/k3d-lab-v3/lab-cruzcloud` desde una sesión `lab` real
|
||||||
|
hasta que se preguntó explícitamente por esto. `ls` devolvía "No such
|
||||||
|
file or directory" pese a que el volumen de `/DATA` estaba montado y
|
||||||
|
"andando".
|
||||||
|
|
||||||
|
**Fix:** montar también `-v /media/HDD-Storage:/media/HDD-Storage`,
|
||||||
|
para que el symlink resuelva exactamente igual que en el host.
|
||||||
|
|
||||||
|
## Gotcha: git "dubious ownership" dentro del contenedor
|
||||||
|
|
||||||
|
Con el mount anterior ya arreglado, `git status`/`git log` dentro del
|
||||||
|
contenedor fallan con:
|
||||||
|
|
||||||
|
```
|
||||||
|
fatal: detected dubious ownership in repository at '/media/HDD-Storage/...'
|
||||||
|
```
|
||||||
|
|
||||||
|
Los archivos en `/DATA`/`/media/HDD-Storage` pertenecen al usuario
|
||||||
|
`devops` del host (via samba), pero los procesos dentro del
|
||||||
|
contenedor corren como `root` (UID 0) — git, desde la versión que
|
||||||
|
introdujo la protección CVE-2022-24765, rechaza operar sobre un repo
|
||||||
|
cuyo dueño no coincide con el UID del proceso, a menos que el path
|
||||||
|
esté explícitamente marcado como seguro.
|
||||||
|
|
||||||
|
**Fix:** `git config --global --add safe.directory '*'` dentro del
|
||||||
|
contenedor. El wildcard (en vez de listar cada repo) es intencional
|
||||||
|
acá: este contenedor ya tiene acceso root-equivalente al host vía el
|
||||||
|
volumen de `/var/run/docker.sock`, así que restringir `safe.directory`
|
||||||
|
por repo no suma aislamiento real.
|
||||||
|
|
||||||
|
**Por qué está en el comando de arranque y no como paso manual
|
||||||
|
aparte:** igual que los paquetes `apk`, esta configuración vive en el
|
||||||
|
filesystem efímero del contenedor (`/root/.gitconfig`, que no está
|
||||||
|
bajo el volumen de `/root/.claude`) — se perdería si el contenedor se
|
||||||
|
recrea. Ponerla en el `sh -c` de arranque la deja recreándose sola
|
||||||
|
cada vez, sin pasos manuales que recordar.
|
||||||
|
|
||||||
|
## Gotcha: la confianza del folder no persistía
|
||||||
|
|
||||||
|
Al abrir `claude` por primera vez en un directorio, pide confirmar un
|
||||||
|
diálogo de "¿confiás en este folder?" (más aún acá, porque este repo
|
||||||
|
trae un `.claude/settings.local.json` con permisos pre-aprobados). Esa
|
||||||
|
confirmación se guarda en `~/.claude.json` — un **archivo suelto en
|
||||||
|
`/root`**, no dentro de la carpeta `~/.claude/` que ya estaba montada
|
||||||
|
como volumen. Resultado: aunque el login (`~/.claude/.credentials.json`)
|
||||||
|
sí persistía, el diálogo de confianza volvía a aparecer cada vez que
|
||||||
|
se recreaba el contenedor — justo el tipo de "reconfigurar de nuevo"
|
||||||
|
que este setup existe para evitar.
|
||||||
|
|
||||||
|
**Cómo se detectó:** al confirmar el diálogo por primera vez, se
|
||||||
|
comparó `/root/.claude.json` dentro del contenedor contra sí mismo
|
||||||
|
antes y después — mismo tamaño y mismo `mtime`, es decir, la
|
||||||
|
confirmación en pantalla nunca se había registrado. La causa real:
|
||||||
|
la primera vez se movió el selector con las flechas pero nunca se
|
||||||
|
presionó Enter, así que en rigor el diálogo seguía sin confirmar; una
|
||||||
|
vez confirmado *de verdad* (`tmux send-keys` moviendo el cursor a
|
||||||
|
"Yes, I trust this folder" + Enter), el archivo saltó de 2.2 KB a
|
||||||
|
40 KB y apareció la clave `projects` con la ruta del repo adentro.
|
||||||
|
|
||||||
|
**Fix:** copiar ese `.claude.json` ya confirmado a
|
||||||
|
`/DATA/AppData/k3d-lab-v3/claude-code-home.json` en el host, y
|
||||||
|
montarlo **archivo a archivo** (no como carpeta) en
|
||||||
|
`-v .../claude-code-home.json:/root/.claude.json`. A diferencia de
|
||||||
|
los demás volúmenes de este setup, este necesitó sembrarse con
|
||||||
|
contenido real primero — un bind mount de archivo con un origen que
|
||||||
|
no existe todavía en el host termina creando un directorio vacío en
|
||||||
|
su lugar, no un archivo, y rompe el arranque del contenedor.
|
||||||
|
Verificado en vivo: tras recrear el contenedor con este mount, `claude`
|
||||||
|
arrancó directo en la sesión normal, sin volver a pedir el diálogo de
|
||||||
|
confianza.
|
||||||
|
|
||||||
|
Sobre paquetes: alpine (imagen `alpine` sin pin de versión, la misma
|
||||||
|
que uso este setup) sí trae `kubectl` en sus repos — no hizo falta
|
||||||
|
cambiar de imagen base ni instalarlo por binario aparte, contrario a
|
||||||
|
lo que se sospechaba inicialmente. `nodejs`/`npm` (v24/v11 al momento
|
||||||
|
de escribir esto) también están en los repos de alpine y cumplen de
|
||||||
|
sobra el mínimo que pide Claude Code.
|
||||||
|
|
||||||
|
**Nota sobre `--network host` + `docker.sock`:** el contenedor tiene,
|
||||||
|
en la práctica, control equivalente a root sobre el NAS (puede lanzar
|
||||||
|
cualquier otro contenedor con cualquier mount). Es una concesión
|
||||||
|
deliberada — es exactamente lo que hace falta para seguir operando
|
||||||
|
Docker/k3d desde dentro — no un descuido.
|
||||||
|
|
||||||
|
## Reconexión: un solo comando
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-session.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
El script (`scripts/lab-session.sh`) es intencionalmente el mismo
|
||||||
|
comando sin importar el estado previo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
|
||||||
|
docker exec -it "$CONTAINER" tmux new -s "$SESSION"
|
||||||
|
```
|
||||||
|
|
||||||
|
Si la sesión `lab` existe, se adjunta a ella (mismo shell, mismo
|
||||||
|
`claude` corriendo, mismo directorio de trabajo). Si no existe —por
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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é
|
||||||
|
|
||||||
|
| 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í | Sí — el proceso `claude` ni se entera |
|
||||||
|
| `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, 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 |
|
||||||
|
|
||||||
|
## Validación en vivo
|
||||||
|
|
||||||
|
Confirmado en este mismo lab (2026-08-27): se dejó una variable de
|
||||||
|
entorno (`export MARCA_PERSISTENCIA=ok_<timestamp>`) corriendo dentro
|
||||||
|
de la sesión `lab` vía `tmux send-keys`, y una invocación
|
||||||
|
*completamente separada* de `docker exec` (equivalente a una nueva
|
||||||
|
conexión) leyó de vuelta el mismo valor — confirma que el estado vive
|
||||||
|
en el servidor tmux del contenedor, no en el cliente que se conecta.
|
||||||
|
`claude --version` respondió `2.1.250` dentro de esa misma sesión, y
|
||||||
|
un archivo de prueba escrito en `/root/.claude` dentro del contenedor
|
||||||
|
apareció de inmediato en
|
||||||
|
`/DATA/AppData/k3d-lab-v3/claude-code-home` en el host, confirmando
|
||||||
|
el bind mount de credenciales.
|
||||||
|
|
||||||
|
Después de agregar el mount de `/media/HDD-Storage` y el
|
||||||
|
`safe.directory`, se validó además: `md5sum` de `CLAUDE.md` idéntico
|
||||||
|
leído desde el host y desde dentro del contenedor (vía el path con
|
||||||
|
symlink `/DATA/AppData/...`), y `git log`/`git status` corriendo sin
|
||||||
|
error sobre `apps-registry` y `scripts` desde una shell dentro de
|
||||||
|
`tmux-host`.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Visión general
|
# Visión general
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrativa completa explicando cada capa del
|
Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
|
||||||
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
|
presupuesto de nube — cada capa del diagrama de abajo existe por una
|
||||||
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
|
razón concreta de ese contexto, no por moda arquitectónica. Esta
|
||||||
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
|
página explica el "por qué" de cada frontera; el "cómo" vive en las
|
||||||
> acompaña es solo el esqueleto.
|
páginas enlazadas al final de cada sección.
|
||||||
|
|
||||||
## Flujo de una petición pública
|
## Flujo de una petición pública
|
||||||
|
|
||||||
@@ -40,4 +40,52 @@ flowchart LR
|
|||||||
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
|
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
|
||||||
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
|
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
|
||||||
|
|
||||||
|
## Por qué cada frontera está donde está
|
||||||
|
|
||||||
|
**Cloudflare Tunnel, no port-forwarding.** El NAS vive detrás de NAT
|
||||||
|
residencial, sin IP pública fija. En vez de abrir puertos en el router
|
||||||
|
(superficie de ataque directa contra el hogar), `cloudflared` abre una
|
||||||
|
conexión **saliente** hacia Cloudflare y todo el tráfico público viaja
|
||||||
|
hacia adentro por esa misma conexión — el origen nunca expone un
|
||||||
|
puerto escuchando en internet. El transporte de esa conexión se fijó a
|
||||||
|
HTTP/2 en vez del QUIC por defecto tras un incidente real de
|
||||||
|
inestabilidad; ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
||||||
|
|
||||||
|
**Nginx Proxy Manager, fuera del cluster.** No todo lo que sirve
|
||||||
|
`*.cruzcloud.net` vive dentro de k3d — Gitea, por ejemplo, corre como
|
||||||
|
contenedor Docker aparte. NPM es el único punto de enrutamiento por
|
||||||
|
dominio que necesita conocer *ambos* mundos (cluster y no-cluster), así
|
||||||
|
que vive fuera de Kubernetes a propósito, no por descuido.
|
||||||
|
|
||||||
|
**`k3d-lab-cluster-serverlb`, la frontera de entrada al cluster.** Es
|
||||||
|
el load balancer que el propio k3d monta automáticamente para publicar
|
||||||
|
puertos del host hacia los nodos internos — no es un componente que
|
||||||
|
este lab configuró a mano, viene con la topología multi-nodo elegida
|
||||||
|
en [ADR 0001](../decisiones/0001-por-que-k3d.md).
|
||||||
|
|
||||||
|
**Traefik, enrutamiento por `Host` dentro del cluster.** Una vez el
|
||||||
|
tráfico entra al cluster por un único puerto (90/9443), Traefik es
|
||||||
|
quien decide a qué Service mandarlo según el header `Host` de cada
|
||||||
|
petición — así NPM solo necesita saber "todo `*.cruzcloud.net` va al
|
||||||
|
puerto 90 del cluster", sin tener que aprender un forward_host nuevo
|
||||||
|
cada vez que se agrega un servicio interno. Cuando esta capa falla en
|
||||||
|
silencio (`forward_host`/`forward_port` mal apuntado en NPM), el
|
||||||
|
síntoma es siempre el mismo: "internamente sano, públicamente roto" —
|
||||||
|
ver [Red y exposición](red-y-exposicion.md).
|
||||||
|
|
||||||
|
**Argo CD, el único escritor del cluster.** Ningún pipeline de CI ni
|
||||||
|
ninguna persona corre `kubectl apply` contra producción — Argo CD es
|
||||||
|
la única pieza con permisos de escritura, y reconstruye
|
||||||
|
automáticamente cualquier diferencia entre Git y el cluster real
|
||||||
|
(`selfHeal: true`). El porqué completo de esa elección está en
|
||||||
|
[ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md); el paso a
|
||||||
|
paso de un commit real hasta quedar corriendo, en
|
||||||
|
[Flujo GitOps](gitops-flujo.md).
|
||||||
|
|
||||||
|
**Gitea, autoalojado.** Además de alojar el código, Gitea corre el CI
|
||||||
|
(Gitea Actions) y el registry de imágenes en el mismo lugar — un solo
|
||||||
|
componente autoalojado en vez de depender de la disponibilidad y los
|
||||||
|
límites de un servicio externo gratuito para un lab que ya de por sí
|
||||||
|
no tiene presupuesto de nube.
|
||||||
|
|
||||||
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
|
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
|
||||||
@@ -1,33 +1,67 @@
|
|||||||
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||||
|
|
||||||
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
**Estado:** aceptada
|
||||||
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
|
||||||
> costo cero vs. cluster gestionado).
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## Contexto
|
||||||
|
|
||||||
TODO
|
El lab corre sobre un NAS ZimaOS doméstico, sin presupuesto para un
|
||||||
|
cluster gestionado y con recursos modestos y compartidos con otras
|
||||||
|
apps del NAS (Gitea, Nginx Proxy Manager, este mismo portal). El
|
||||||
|
propósito del lab es doble: servir de portafolio técnico real y de
|
||||||
|
material de estudio para el hijo del usuario (7mo semestre de
|
||||||
|
Ingeniería de Sistemas) — ver [guía del estudiante](../guia-estudiante/README.md).
|
||||||
|
Para que ambos objetivos tengan sentido, el cluster necesitaba
|
||||||
|
comportarse como un Kubernetes real y multi-nodo, no como una caja de
|
||||||
|
arena de un solo proceso: patrones como afinidad de pods, StatefulSets
|
||||||
|
con almacenamiento persistente por nodo, y tráfico cross-node debían
|
||||||
|
poder reproducirse y depurarse de verdad, no simularse.
|
||||||
|
|
||||||
|
También pesó la restricción del propio sistema operativo: `/` en
|
||||||
|
ZimaOS es un `squashfs` de solo lectura y `/usr` es un overlay
|
||||||
|
igualmente inmutable — instalar un binario nativo como `k3s` que
|
||||||
|
espera escribir en `/usr/local/bin`, `/etc/rancher`, etc. de forma
|
||||||
|
persistente no encaja bien con ese filesystem sin trabajo extra de
|
||||||
|
por medio.
|
||||||
|
|
||||||
## Opciones consideradas
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| k3d | | |
|
| **k3d** | Multi-nodo real corriendo como contenedores Docker (server + agent), se crea/destruye en segundos, no toca el filesystem inmutable de ZimaOS más allá de `/DATA` | Al ser Docker-in-Docker, hereda las capas de red de Docker — un blackhole de MTU/PMTUD real apareció por esto (ver Consecuencias) |
|
||||||
| k3s directo (sin Docker) | | |
|
| k3s directo (sin Docker) | Sin la capa extra de red de Docker-in-Docker, más cercano a un nodo "real" | Necesita instalar servicios systemd y escribir en rutas de sistema (`/usr/local/bin`, `/etc/rancher`) que en ZimaOS son de solo lectura o requieren workarounds — solo `/DATA` y `/etc` quedan escribibles |
|
||||||
| minikube | | |
|
| minikube | Instalación simple, muy documentado, ideal para aprender lo básico | Single-node por defecto — no permite reproducir tráfico cross-node, afinidad de pods, ni el tipo de incidentes de infraestructura real que este lab busca documentar como aprendizaje |
|
||||||
| Cluster gestionado (cloud) | | |
|
| Cluster gestionado (cloud) | Cero mantenimiento de infraestructura, SLA real | Costo recurrente que este lab no tiene presupuestado; además reduce el valor de aprendizaje — gran parte de lo documentado en `playbooks/` (MTU, DNS, límites de recursos) es justamente lo que un proveedor gestionado abstrae |
|
||||||
|
|
||||||
## Decisión
|
## Decisión
|
||||||
|
|
||||||
TODO
|
k3d, multi-nodo (hoy: 1 server + 1 agent, `k3d-lab-cluster-server-0` /
|
||||||
|
`k3d-lab-cluster-agent-0`), corriendo como contenedores Docker sobre
|
||||||
|
el propio NAS.
|
||||||
|
|
||||||
## Consecuencias
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar explícitamente el blackhole de red cross-node
|
- **Se ganó** un cluster real y gratuito, reproducible con un solo
|
||||||
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
|
||||||
sobre Docker-in-Docker sin ajuste de MTU (ver
|
Kubernetes de producción como para que los incidentes documentados
|
||||||
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
en `playbooks/` sean transferibles a un entorno cloud real.
|
||||||
|
- **Se pagó** con un blackhole de red cross-node real: cualquier pod
|
||||||
|
agendado en un nodo distinto al de `commerce-postgres-0`
|
||||||
|
(StatefulSet, fijo a un nodo) fallaba de forma consistente al hablar
|
||||||
|
con Postgres — diagnosticado hasta el bit exacto (payload interno
|
||||||
|
1420 bytes OK, 1450 bytes blackhole total, sin ICMP "frag needed")
|
||||||
|
y resuelto ajustando el MTU real de la interfaz `flannel.1` a 1400
|
||||||
|
en cada nodo. Ver [known-issues.md](https://gitea.cruzcloud.net/devops/apps-registry/src/branch/main/docs/known-issues.md)
|
||||||
|
y el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)
|
||||||
|
para el diagnóstico completo. El fix no persiste solo — está
|
||||||
|
automatizado en `scripts/deploy-lab.sh` (función `fix_flannel_mtu`),
|
||||||
|
se reaplica cada vez que el cluster arranca o se recrea.
|
||||||
|
- **Deuda aceptada a propósito**: el `podAffinity` que fuerza a
|
||||||
|
`medusa-deploy` a compartir nodo con Postgres se dejó como red de
|
||||||
|
contención extra después del fix de fondo — no es estrictamente
|
||||||
|
necesario, pero es barato mantenerlo.
|
||||||
|
- **Para revertir esta decisión** haría falta migrar a un cluster
|
||||||
|
gestionado o a `k3s` nativo, lo que implicaría asumir el costo
|
||||||
|
recurrente (cloud) o resolver las restricciones de filesystem
|
||||||
|
inmutable de ZimaOS (k3s nativo) — ninguna de las dos se justifica
|
||||||
|
hoy con el tamaño real del lab.
|
||||||
@@ -1,30 +1,80 @@
|
|||||||
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
||||||
|
|
||||||
> **TODO (fase 2+):** completar con el criterio real detrás de elegir
|
**Estado:** aceptada
|
||||||
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> CI.
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## Contexto
|
||||||
|
|
||||||
TODO
|
Desde el principio el lab iba a tener más de un repo Git (hoy:
|
||||||
|
`apps-registry` para las Applications y los manifiestos reales de los
|
||||||
|
workloads, `platform-infra` para la gobernanza de cada namespace,
|
||||||
|
`scripts` para bootstrap/operación) y más de una app corriendo en el
|
||||||
|
cluster (ecommerce, docs-portal, nginx, cada una con su propia
|
||||||
|
gobernanza). Se necesitaba una forma de mantener el estado del
|
||||||
|
cluster sincronizado con lo declarado en Git que escalara a "agregar
|
||||||
|
una app nueva" sin volverse un proceso manual que dependiera de que
|
||||||
|
una sola persona recordara qué `kubectl apply` correr y en qué orden.
|
||||||
|
|
||||||
|
También importaba la trazabilidad: en un lab que sirve de portafolio,
|
||||||
|
poder señalar "este commit es exactamente lo que está corriendo en
|
||||||
|
producción" (y por qué) tiene valor por sí mismo — no solo operativo.
|
||||||
|
|
||||||
## Opciones consideradas
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Argo CD (GitOps, pull-based) | | |
|
| **Argo CD (GitOps, pull-based)** | Reconciliación continua: un proceso dentro del cluster compara el estado real contra Git y corrige la diferencia solo, sin que nadie tenga que acordarse de aplicar nada. Patrón App of Apps nativo — agregar una app nueva es un archivo YAML más en `apps/`, no un paso manual nuevo | Componente extra que mantener sano (su propio namespace, su propio consumo de recursos) |
|
||||||
| `kubectl apply` manual | | |
|
| `kubectl apply` manual | Cero infraestructura extra, control total en cada comando | Sin detección de drift — si alguien edita algo a mano y lo olvida, nadie se entera hasta que rompe; no escala más allá de un solo operador que recuerde el estado completo de memoria |
|
||||||
| `kubectl apply` desde el pipeline de CI (push-based) | | |
|
| `kubectl apply` desde el pipeline de CI (push-based) | No agrega un componente nuevo al cluster, reutiliza el CI que ya existe para construir imágenes | El pipeline de CI necesitaría credenciales de escritura amplias sobre el cluster — más superficie de riesgo si esas credenciales se filtran. Push-based además significa que nada vuelve a comparar el estado después de aplicado: un drift posterior (alguien con `kubectl` directo) queda invisible hasta el siguiente deploy |
|
||||||
|
|
||||||
## Decisión
|
## Decisión
|
||||||
|
|
||||||
TODO
|
Argo CD, patrón App of Apps: una Application raíz
|
||||||
|
(`apps-registry/application.yaml`) vigila la carpeta `apps/`, y cada
|
||||||
|
archivo ahí dentro es una Application hija que apunta a un workload
|
||||||
|
real. Toda Application se declara con el mismo `syncPolicy`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
```
|
||||||
|
|
||||||
|
(configuración real de `apps/ecommerce-app.yaml`, repetida en cada
|
||||||
|
Application del repo)
|
||||||
|
|
||||||
## Consecuencias
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
|
||||||
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
cluster de verdad.** No es solo que Argo CD detecte diferencias y
|
||||||
"quién puede tocar el cluster".
|
las muestre como `OutOfSync` — las revierte activamente. Si alguien
|
||||||
|
corre `kubectl scale` o `kubectl edit` directo contra un recurso
|
||||||
|
gestionado, en el siguiente ciclo de reconciliación (poll periódico,
|
||||||
|
o inmediato si el commit llegó por webhook) Argo CD lo vuelve a
|
||||||
|
dejar exactamente como dice Git. Git deja de ser "la intención" y
|
||||||
|
pasa a ser literalmente la única fuente de la que el cluster puede
|
||||||
|
aprender su propio estado — ver [flujo GitOps](../arquitectura/gitops-flujo.md).
|
||||||
|
- **`prune: true`** significa que borrar un recurso del repo (por
|
||||||
|
ejemplo, quitar `meilisearch.yaml` de una `kustomization.yaml`) lo
|
||||||
|
borra también del cluster en el siguiente sync, sin un paso manual
|
||||||
|
de limpieza — confirmado en la práctica en varias fases de este lab
|
||||||
|
(ver los ADRs de rediseño del storefront y de Meilisearch).
|
||||||
|
- **El rol de CI se redujo a "construir, publicar imagen, y dejar un
|
||||||
|
commit en Git"** — nunca aplica nada directo al cluster. Esto evitó
|
||||||
|
tener que darle a CI credenciales de cluster, y dejó un problema real
|
||||||
|
documentado (la condición de carrera de promoción cuando dos
|
||||||
|
pipelines corren en serie sobre el mismo commit) resoluble
|
||||||
|
comparando contra Git (`git merge-base --is-ancestor`) en vez de
|
||||||
|
contra el estado del cluster.
|
||||||
|
- **Se paga con un componente más que mantener sano**: Argo CD corre
|
||||||
|
en su propio namespace, con su propio consumo de recursos y su
|
||||||
|
propia superficie de fallo — un Argo CD caído no rompe lo que ya
|
||||||
|
está corriendo (el cluster sigue con el último estado aplicado),
|
||||||
|
pero sí detiene la sincronización de cambios nuevos.
|
||||||
|
- **Para revertir esta decisión** haría falta volver a un modelo
|
||||||
|
push-based (CI aplicando directo) o puramente manual, perdiendo la
|
||||||
|
reconciliación continua y aceptando que un drift manual pueda pasar
|
||||||
|
desapercibido.
|
||||||
@@ -109,7 +109,10 @@ Un motor de búsqueda dedicado (Meilisearch, autohospedado) queda
|
|||||||
identificado como el siguiente paso natural una vez el catálogo
|
identificado como el siguiente paso natural una vez el catálogo
|
||||||
crezca más allá de ~100 productos activos, pero no es parte del
|
crezca más allá de ~100 productos activos, pero no es parte del
|
||||||
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
|
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
|
||||||
cerca.
|
cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver
|
||||||
|
[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó
|
||||||
|
ser la complejidad de facetas de la taxonomía, no el volumen de
|
||||||
|
productos — ver [ADR 0006](0006-motor-busqueda-facetas.md).
|
||||||
|
|
||||||
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
|
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
|
||||||
opciones evaluadas, incluida la elegida, toca
|
opciones evaluadas, incluida la elegida, toca
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ contra estos 9 productos demo:
|
|||||||
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
||||||
PR #42).
|
PR #42).
|
||||||
|
|
||||||
**Pendiente:** borrar el usuario admin temporal `[email protected]`
|
El usuario admin temporal `[email protected]` (creado para la
|
||||||
de Medusa Admin (creado para la auditoría de la sección 5, ya no
|
auditoría de la sección 5) fue borrado el 2026-08-27 vía Medusa Admin.
|
||||||
necesario). Sin próxima etapa definida todavía — este ADR no
|
Sin próxima etapa definida todavía — este ADR no especifica una
|
||||||
especifica una Etapa 3.
|
Etapa 3.
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3)
|
||||||
|
|
||||||
|
**Estado:** aceptada — Etapa 2 en implementación (aprobada 2026-08-26 tras diagnóstico de memoria del host)
|
||||||
|
**Fecha:** 2026-08-25
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
[ADR 0004](0004-evaluacion-stack-multi-categoria.md) ya identificó un
|
||||||
|
motor de búsqueda dedicado (Meilisearch, autohospedado) como "el
|
||||||
|
siguiente paso natural" para ARI Shopping, pero lo dejó fuera del
|
||||||
|
alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al
|
||||||
|
techo real de ~100 productos activos que impone
|
||||||
|
`fetchProducts(filters, limit = 100)` en `medusa.ts`.
|
||||||
|
|
||||||
|
**Ese techo todavía no llegó** — el catálogo real hoy son 26 productos
|
||||||
|
(17 legacy + 9 demo, ver [ADR 0005](0005-rediseno-storefront.md)
|
||||||
|
sección 5), lejos de 100. El disparador de esta propuesta no es el
|
||||||
|
techo de volumen sino la **complejidad de facetas**: la Etapa 2 ya
|
||||||
|
construyó una taxonomía real de 6 categorías raíz / 21 nodos
|
||||||
|
(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con
|
||||||
|
conteo por valor (`categoryCounts`/`brandCounts`/`ageCounts` en
|
||||||
|
`catalog-client.tsx`, PR #40) que hoy se calcula **100% client-side**
|
||||||
|
sobre los productos ya traídos — el mismo patrón de "deuda de
|
||||||
|
alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más
|
||||||
|
dimensiones de filtro que multiplicar. Sigue siendo tratable a 26
|
||||||
|
productos; deja de serlo mucho antes de llegar a 100 si el catálogo
|
||||||
|
crece a la vez en número de productos y en dimensiones filtrables.
|
||||||
|
|
||||||
|
Restricción no negociable, igual que en toda la Fase 5: el flujo
|
||||||
|
**Admin → evento `product.updated` → `revalidate-storefront.ts` →
|
||||||
|
`revalidateTag("medusa-products")`**, que hoy mantiene el storefront
|
||||||
|
sincronizado sin intervención manual, no se toca. Cualquier índice de
|
||||||
|
búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca
|
||||||
|
por un cron ni un paso manual.
|
||||||
|
|
||||||
|
## 1. Estado real de la integración Medusa v2 + Meilisearch
|
||||||
|
|
||||||
|
Verificado contra la documentación oficial vigente el 2026-08-25, no
|
||||||
|
contra memoria de entrenamiento:
|
||||||
|
|
||||||
|
- **Medusa no tiene un módulo de búsqueda propio integrado con
|
||||||
|
Meilisearch hoy.** La guía oficial
|
||||||
|
(`docs.medusajs.com/resources/integrations/guides/meilisearch`) es
|
||||||
|
un tutorial de módulo custom desde cero (`npm install meilisearch` +
|
||||||
|
workflows/subscribers propios) — no un paquete instalable.
|
||||||
|
- **Medusa sí introdujo un "Search Module" propio en la versión
|
||||||
|
2.19.0.** El paquete comunitario `@rokmohar/medusa-plugin-meilisearch`
|
||||||
|
(el más usado y mantenido para Meilisearch+Medusa) se conecta a ese
|
||||||
|
Search Module desde su versión `^2.0.0`, pero esa versión del plugin
|
||||||
|
**requiere Medusa `^2.19.0`**.
|
||||||
|
- **Nuestro backend corre Medusa `2.18.0`** (`commerce-backend/package.json`)
|
||||||
|
— justo por debajo de ese corte. La tabla de compatibilidad del
|
||||||
|
plugin (`github.com/rokmohar/medusa-plugin-meilisearch`) resuelve
|
||||||
|
esto: la línea `^1.4.1` soporta `Medusa >= 2.15 < 2.19`, servidor
|
||||||
|
Meilisearch `>= 1.5`, Node `>= 22`. El propio repo lo explica: *"The
|
||||||
|
2.19 release removed the search interface the v1 line was written
|
||||||
|
against, so the two lines do not overlap: v1 stops at Medusa 2.18,
|
||||||
|
v2 starts at 2.19."*
|
||||||
|
- Node de `commerce-backend` ya es `22.18.0` (Dockerfile) — cumple el
|
||||||
|
mínimo del plugin sin cambios.
|
||||||
|
|
||||||
|
**Implicación concreta:** para adoptar Meilisearch sin subir de
|
||||||
|
versión Medusa (fuera de alcance de este ADR — sería su propia
|
||||||
|
migración), la pieza a instalar es `@rokmohar/medusa-plugin-meilisearch@^1.4.1`,
|
||||||
|
no la última versión del plugin. Esto es deuda técnica documentada,
|
||||||
|
no un obstáculo: si en el futuro se sube Medusa a `2.19+`, migrar a la
|
||||||
|
línea `^2.0.0` del plugin (y al Search Module nativo) es el camino
|
||||||
|
recto.
|
||||||
|
|
||||||
|
## 2. Impacto de recursos
|
||||||
|
|
||||||
|
Estado real del host ZimaOS al momento de esta auditoría (`free -h`,
|
||||||
|
`docker stats`): 15 GiB RAM totales, **swap al 100% (5.2/5.2 GiB
|
||||||
|
usados)**, solo 351 MiB realmente libres (6.5 GiB "available" vía
|
||||||
|
cache reclamable), 4 vCPU — la misma clase de contención que ya forzó
|
||||||
|
el fix de `cpu_shares` de Gitea. Cualquier servicio nuevo en este host
|
||||||
|
es una decisión de capacidad real, no teórica.
|
||||||
|
|
||||||
|
- **Meilisearch no publica un mínimo de RAM oficial.** Su propio FAQ
|
||||||
|
(`meilisearch.com/docs/resources/help/faq`) solo dice que "más RAM
|
||||||
|
implica búsquedas más rápidas" y recomienda ~10x el tamaño del
|
||||||
|
dataset crudo en disco — sin piso duro. La cifra de "~1 GiB
|
||||||
|
cómodo" que circula en blogs de la comunidad es una estimación, no
|
||||||
|
una garantía del proveedor — se cita aquí como referencia, no como
|
||||||
|
hecho verificado.
|
||||||
|
- Meilisearch usa almacenamiento memory-mapped: el índice puede vivir
|
||||||
|
parcialmente en disco cuando no entra en RAM, degradando latencia en
|
||||||
|
vez de fallar. A la escala actual (26 productos, dataset de KB) el
|
||||||
|
uso real de RAM del índice en sí es insignificante — el costo es el
|
||||||
|
proceso base de Meilisearch, no el dataset.
|
||||||
|
- **Comparación con Typesense** (pedida explícitamente, mantenida
|
||||||
|
breve porque la diferencia relevante para este caso es clara y no
|
||||||
|
requiere más profundidad): Typesense mantiene el índice completo en
|
||||||
|
RAM (~30% más RAM que Meilisearch para el mismo dataset, según
|
||||||
|
benchmarks públicos) y, a diferencia de Meilisearch, **no tiene
|
||||||
|
ningún plugin ni integración con Medusa** — habría que escribir la
|
||||||
|
sincronización completa desde cero igual que con la guía "custom"
|
||||||
|
de Meilisearch, pero sin ninguna base comunitaria mantenida
|
||||||
|
compatible con nuestra versión de Medusa. Con Meilisearch ya
|
||||||
|
teniendo un plugin activo para `Medusa 2.18` y menor huella de RAM,
|
||||||
|
no hay motivo para evaluar Typesense más a fondo en este caso.
|
||||||
|
- **Recomendación:** desplegar Meilisearch en k3d con `requests`/`limits`
|
||||||
|
explícitos y conservadores desde el día uno (ej. `request: 256Mi/0.1
|
||||||
|
CPU`, `limit: 768Mi/0.5 CPU` — a ajustar con datos reales de uso, no
|
||||||
|
supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya
|
||||||
|
se vivió con Gitea: monitorear consumo real post-despliegue antes de
|
||||||
|
darlo por estable. Dado el swap ya saturado, este es el punto de
|
||||||
|
mayor incertidumbre de toda la propuesta — más que la elección de
|
||||||
|
motor de búsqueda en sí.
|
||||||
|
|
||||||
|
## 3. Modelo de indexación
|
||||||
|
|
||||||
|
Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en
|
||||||
|
`Product`/`Category` de `lib/headless/types.ts`:
|
||||||
|
|
||||||
|
| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | Sí | No | No |
|
||||||
|
| `shortDescription` / `description` | Sí | No | No |
|
||||||
|
| `category` (nombre de la hoja real) | Sí | Sí (con conteo) | No |
|
||||||
|
| `collection` | Sí | Sí (con conteo) | No |
|
||||||
|
| `brand` | Sí | Sí (con conteo) | No |
|
||||||
|
| `ageRange` | No | Sí (con conteo) | No |
|
||||||
|
| `tags` | Sí | No | No |
|
||||||
|
| `price` | No | Sí (rango) | Sí |
|
||||||
|
| `compareAtPrice` | No | No | No (solo se usa para el badge, no como filtro) |
|
||||||
|
| `isNew` / `featured` | No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) |
|
||||||
|
| `available` | No | Sí (booleano) | No |
|
||||||
|
| `slug`, `thumbnail`, `id` | No | No | No (solo se devuelven, no se indexan como facetas) |
|
||||||
|
|
||||||
|
La jerarquía de categorías (raíz/hija) no se indexa como un campo
|
||||||
|
aparte: el índice guarda el nombre de la hoja real tal como hoy lo
|
||||||
|
hace `product.category`, y el breadcrumb/mega-menú siguen resolviendo
|
||||||
|
la jerarquía contra `/store/product-categories` (sin cambios, ya
|
||||||
|
funciona desde la sub-fase 2). El motor de búsqueda resuelve
|
||||||
|
**búsqueda de texto + conteo de facetas**, no reemplaza la fuente de
|
||||||
|
la jerarquía.
|
||||||
|
|
||||||
|
## 4. Diseño de sincronización
|
||||||
|
|
||||||
|
Mismo patrón que `revalidate-storefront.ts`, no uno nuevo: un
|
||||||
|
subscriber adicional en `commerce-backend/src/subscribers/` (ej.
|
||||||
|
`sync-search-index.ts`) que reacciona a eventos y le pide al plugin
|
||||||
|
que reindexe el documento afectado — nunca Medusa escribiendo
|
||||||
|
directamente en Meilisearch fuera de este canal.
|
||||||
|
|
||||||
|
Eventos a escuchar, confirmados en la documentación oficial de
|
||||||
|
eventos de Medusa (`docs.medusajs.com/resources/events-reference`):
|
||||||
|
|
||||||
|
- `product.created` / `product.updated` / `product.deleted`
|
||||||
|
- `product-variant.created` / `product-variant.updated` / `product-variant.deleted`
|
||||||
|
(mismo set que ya usa `revalidate-storefront.ts`, hoy validado en
|
||||||
|
producción)
|
||||||
|
- **Nuevo respecto al subscriber actual:** `product-category.created`
|
||||||
|
/ `product-category.updated` / `product-category.deleted` — un
|
||||||
|
cambio de nombre o jerarquía de categoría debe refrescar las
|
||||||
|
facetas del índice, y hoy `revalidate-storefront.ts` no escucha
|
||||||
|
estos eventos porque el storefront los resuelve en cada request
|
||||||
|
contra `/store/product-categories` directamente, sin necesidad de
|
||||||
|
revalidación. El índice de búsqueda sí necesita enterarse.
|
||||||
|
|
||||||
|
**Punto a verificar en vivo en Etapa 2, no asumido aquí:** existe al
|
||||||
|
menos un reporte público de un evento `product-collection.updated`
|
||||||
|
que no se disparaba de forma confiable en ciertas versiones de Medusa
|
||||||
|
(`github.com/medusajs/medusa/issues/11197`). Antes de dar por cerrado
|
||||||
|
el diseño de sincronización hay que repetir exactamente la validación
|
||||||
|
que ya se hizo para `revalidate-storefront.ts` en la auditoría de
|
||||||
|
Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y
|
||||||
|
confirmar en vivo que el evento llega al subscriber, no confiar en que
|
||||||
|
la documentación coincide con el comportamiento real de `2.18.0`
|
||||||
|
(el mismo tipo de brecha que ya se encontró una vez con la relación
|
||||||
|
`+categories` de `/store/products`).
|
||||||
|
|
||||||
|
## 5. Diseño de UI — filtros facetados y búsqueda
|
||||||
|
|
||||||
|
**Filtros facetados (sidebar de `/catalog`):** mismo layout que ya
|
||||||
|
construyó la sub-fase 3 (`catalog-client.tsx`, PR #40) — sidebar con
|
||||||
|
"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y
|
||||||
|
slider de precio máximo. Lo único que cambia es **de dónde sale el
|
||||||
|
conteo**: hoy `categoryCounts`/`brandCounts`/`ageCounts` se calculan
|
||||||
|
recorriendo el arreglo de productos ya traído en el cliente; con el
|
||||||
|
motor de búsqueda, esos conteos vienen del propio Meilisearch
|
||||||
|
(`facetDistribution`) en la misma respuesta que trae los productos
|
||||||
|
filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia
|
||||||
|
— sigue resolviéndose contra la jerarquía real de categorías, no
|
||||||
|
contra el índice de búsqueda.
|
||||||
|
|
||||||
|
```
|
||||||
|
Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios)
|
||||||
|
┌────────────┬──────────────────────────────────────┐
|
||||||
|
│ Categoría │ [buscador con autocompletado] │ ← nuevo
|
||||||
|
│ ☐ Marvel(6)│ [card][card][card] │
|
||||||
|
│ ☐ Frozen(4)│ [card][card][card] │
|
||||||
|
│ Marca │ │
|
||||||
|
│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │
|
||||||
|
│ Precio │ no de recorrer el arreglo en cliente │
|
||||||
|
│ [====○===] │ │
|
||||||
|
└────────────┴──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Búsqueda con autocompletado (barra del navbar):** hoy
|
||||||
|
`PredictiveSearch` (`components/search/predictive-search.tsx`) filtra
|
||||||
|
por substring sobre los productos ya cargados en memoria del cliente —
|
||||||
|
funciona a 26 productos, no escala y no tolera errores de tipeo (ADR
|
||||||
|
0004, hallazgo #5). Se reemplaza la fuente de datos del mismo
|
||||||
|
componente visual por `searchProducts(query)` del `CatalogProvider`
|
||||||
|
(interfaz que **ya existe** en `lib/headless/types.ts`, sin necesidad
|
||||||
|
de un tipo nuevo) apuntando a Meilisearch en vez de al `?q=` de
|
||||||
|
Medusa: tolerancia a typos y ranking por relevancia nativos del motor,
|
||||||
|
mismo contrato de datos que ya consume el dropdown de resultados —
|
||||||
|
cero cambio de componente React, solo de qué hay detrás de
|
||||||
|
`CatalogProvider.searchProducts`.
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| **Meilisearch autohospedado** (`@rokmohar/medusa-plugin-meilisearch@^1.4.1`, sincronizado por eventos) | Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que `revalidate-storefront.ts`, cero riesgo para ese flujo | Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea `v1` del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado |
|
||||||
|
| **Typesense autohospedado** | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host |
|
||||||
|
| **Módulo custom desde cero** (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 |
|
||||||
|
| **No adoptar motor de búsqueda todavía** (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**Meilisearch autohospedado**, vía `@rokmohar/medusa-plugin-meilisearch@^1.4.1`
|
||||||
|
(no la última versión del plugin — esa exige Medusa 2.19+, fuera de
|
||||||
|
alcance), sincronizado por el mismo patrón de subscriber por eventos
|
||||||
|
que ya usa `revalidate-storefront.ts`, con `product-category.*` sumado
|
||||||
|
al set de eventos existente. Se despliega en k3d con límites de
|
||||||
|
recursos conservadores desde el primer día y se trata como
|
||||||
|
experimento a monitorear, no como capacidad garantizada, dado el
|
||||||
|
estado actual de memoria del host.
|
||||||
|
|
||||||
|
**No se sube la versión de Medusa como parte de esta decisión** —
|
||||||
|
queda como deuda técnica documentada para cuando se justifique
|
||||||
|
migrar a la línea `v2` del plugin y al Search Module nativo de
|
||||||
|
Medusa.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Se gana: conteo de facetas real (no client-side), búsqueda tolerante
|
||||||
|
a errores de tipeo con ranking por relevancia, y una arquitectura
|
||||||
|
que ya no depende del techo de 100 productos de `fetchProducts`.
|
||||||
|
- Se acepta como deuda técnica: quedar atado a la línea `v1` del
|
||||||
|
plugin de Meilisearch mientras Medusa siga en `2.18.x`; migrar a
|
||||||
|
Medusa `2.19+` es un proyecto aparte, no parte de esta Etapa 3.
|
||||||
|
- Riesgo operativo real y no resuelto todavía por este ADR: el host ya
|
||||||
|
corre con el swap saturado — el despliegue de Etapa 2 debe empezar
|
||||||
|
con límites de recursos conservadores y validación en vivo de
|
||||||
|
consumo real antes de confiar en el servicio, siguiendo la misma
|
||||||
|
lección que dejó el incidente de recursos de Gitea.
|
||||||
|
- Revertir esta decisión implica desinstalar el plugin y el
|
||||||
|
subscriber nuevo, y volver el buscador/filtrado a 100% client-side
|
||||||
|
— no hay migración de datos que deshacer, porque Meilisearch nunca
|
||||||
|
es la fuente de verdad, solo una copia de solo lectura indexada
|
||||||
|
desde Medusa.
|
||||||
|
|
||||||
|
## Nota de implementación (2026-08-26, antes de escribir código de Etapa 2)
|
||||||
|
|
||||||
|
Antes de tocar código se verificó el código fuente real de
|
||||||
|
`@rokmohar/medusa-plugin-meilisearch` en el tag exacto `v1.4.1`
|
||||||
|
(`github.com/rokmohar/medusa-plugin-meilisearch`), no su README
|
||||||
|
genérico ni memoria de entrenamiento — misma disciplina que ya destapó
|
||||||
|
el bug de `+categories` en `/store/products`. Aparecieron tres
|
||||||
|
desviaciones reales respecto al diseño de §4/§5 de este ADR:
|
||||||
|
|
||||||
|
1. **No se escribe un subscriber nuevo.** El plugin trae sus propios
|
||||||
|
subscribers (`meilisearch-product-upsert.ts`,
|
||||||
|
`meilisearch-category-upsert.ts`, variantes/colección/inventario),
|
||||||
|
auto-registrados al instalar el paquete — el propio README dice
|
||||||
|
*"You don't need custom subscribers anymore, you can remove them."*
|
||||||
|
Escribir `sync-search-index.ts` como describe §4 causaría doble
|
||||||
|
indexado. La escucha de `product-category.*` que pedía §4 ya viene
|
||||||
|
incluida en los subscribers del plugin.
|
||||||
|
2. **Las rutas Store API del plugin no devuelven `facetDistribution`**,
|
||||||
|
pese a que §5 asume que sí. El servicio interno
|
||||||
|
(`MeiliSearchService.search()`) sí puede pedirlo vía
|
||||||
|
`additionalOptions: { facets: [...] }`, pero las rutas
|
||||||
|
empaquetadas nunca lo piden ni lo reenvían. Se agrega una ruta
|
||||||
|
Store API propia (`src/api/store/search/route.ts`) que resuelve el
|
||||||
|
servicio del plugin desde el container y arma
|
||||||
|
`{ products, facetDistribution }`. Consecuencia positiva no
|
||||||
|
buscada: Meilisearch queda 100% `ClusterIP`-only, sin Ingress, sin
|
||||||
|
key pública ni ruta de red navegador→Meilisearch — todo pasa por
|
||||||
|
Medusa.
|
||||||
|
3. **El plugin se conecta vía un array `plugins:` nuevo en
|
||||||
|
`medusa-config.ts`**, no otra entrada en `modules:` (el único
|
||||||
|
precedente en este repo, el file-provider S3, usa `modules:`), y
|
||||||
|
usa nombres de env var propios (`MEILISEARCH_HOST`/
|
||||||
|
`MEILISEARCH_API_KEY`, leídos por el plugin) distintos del env var
|
||||||
|
del servidor Meilisearch en sí (`MEILI_MASTER_KEY`, leído por el
|
||||||
|
contenedor `getmeili/meilisearch`) — mismo valor, dos nombres de
|
||||||
|
secret.
|
||||||
|
|
||||||
|
Ninguna de las tres cambia la decisión ni sus consecuencias — cambian
|
||||||
|
el cómo, no el qué. Plan de implementación completo, fase por fase,
|
||||||
|
en la sesión que aprobó esta Etapa 2 (2026-08-26).
|
||||||
|
|
||||||
|
## Nota de implementación 2 (2026-08-26, gate en vivo de Fase 2)
|
||||||
|
|
||||||
|
Dos bugs reales encontrados y corregidos durante el gate de
|
||||||
|
verificación en vivo (crear/editar productos, categoría y colección
|
||||||
|
reales y confirmar que llegan a Meilisearch), ambos con la misma causa
|
||||||
|
raíz: `variants.calculated_price` de Medusa exige `region_id` **y**
|
||||||
|
`currency_code` resueltos en el contexto de pricing de la consulta, y
|
||||||
|
ninguno de los dos caminos nuevos de este ADR pasa por el middleware
|
||||||
|
nativo (`setPricingContext`) que se lo da gratis a `/store/products`:
|
||||||
|
|
||||||
|
1. El job de sync inicial del plugin (`sync-products.js`, corre una
|
||||||
|
vez al boot) hacía fallar el indexado de **todos** los productos
|
||||||
|
con `Method calculatePrices requires currency_code in the pricing
|
||||||
|
context` — las categorías sí indexaban bien (no llevan precio).
|
||||||
|
Arreglado indexando el precio crudo (`variants.prices`, sin
|
||||||
|
resolver promociones/reglas) en vez de `calculated_price` — alcanza
|
||||||
|
para el filtro/orden por precio de la búsqueda.
|
||||||
|
2. La ruta propia `/store/search` pegaba el mismo error al re-hidratar
|
||||||
|
productos vía `query.graph`. Pasar `region_id` solo no alcanzó;
|
||||||
|
hubo que resolver el `currency_code` real de la región con una
|
||||||
|
consulta adicional antes de armar el `QueryContext`.
|
||||||
|
|
||||||
|
Confirmado en vivo, con el fix aplicado: búsqueda por texto, facetas
|
||||||
|
sobre las 27 unidades del catálogo real, y precio calculado correcto
|
||||||
|
(`calculated_amount`/`currency_code`) en la respuesta de
|
||||||
|
`/store/search`. También confirmado en vivo que los tres eventos que
|
||||||
|
el ADR original marcaba como "verificar, no asumir" — `product.*`,
|
||||||
|
`product-category.*` y **`product-collection.updated`** (el que tenía
|
||||||
|
un reporte público de no disparar siempre) — sí sincronizan de forma
|
||||||
|
confiable en esta combinación exacta (Medusa 2.18.0 + plugin 1.4.1),
|
||||||
|
propagando un rename de categoría/colección al documento indexado en
|
||||||
|
menos de 10 segundos.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# ADR 0007 — Identidad visual propia para ARI Shopping
|
||||||
|
|
||||||
|
**Estado:** reemplazada por [ADR 0008](0008-identidad-navy-coral.md) (2026-08-27)
|
||||||
|
**Fecha:** 2026-08-27
|
||||||
|
|
||||||
|
> **Superseded (2026-08-27):** la Opción 2 "Atelier cálido" (ciruela/
|
||||||
|
> carbón/cobre + Domine) descrita en este ADR se descartó — no
|
||||||
|
> convenció combinada con el resto del sitio sin cambiar (header,
|
||||||
|
> footer, home y producto seguían con la paleta anterior, ver §7). El
|
||||||
|
> ADR 0008 documenta la dirección final ("Navy & Coral") y su
|
||||||
|
> cronología completa. Este documento se conserva como registro
|
||||||
|
> histórico, sin editar el resto de su contenido.
|
||||||
|
|
||||||
|
> **Corrección de dirección #1 (2026-08-27):** la primera versión de
|
||||||
|
> este ADR proponía clonar la estética visual de Amazon. Se corrigió a
|
||||||
|
> adoptar *principios* de UX, no la estética de un competidor —
|
||||||
|
> resultó en una paleta "cálida, confiable, ligeramente lúdica"
|
||||||
|
> (terracota/espresso/pino/crema, Fraunces).
|
||||||
|
|
||||||
|
> **Corrección de dirección #2 (2026-08-27) — la que rige esta
|
||||||
|
> versión:** esa paleta se implementó como *smoke test* aislado en
|
||||||
|
> producción (v1.0.176: solo el botón CTA de `ProductCard` en
|
||||||
|
> terracota + el precio en Fraunces) y se revirtió por dos motivos
|
||||||
|
> reales, no de gusto superficial:
|
||||||
|
>
|
||||||
|
> 1. **Error de proceso**: un token aislado rodeado del resto del
|
||||||
|
> esquema viejo (badges en fucsia/amarillo neón, fondo blanco) no
|
||||||
|
> es evaluable — el choque visual que se vio no era "la dirección
|
||||||
|
> de marca", era la mezcla incompleta. Ningún componente aislado en
|
||||||
|
> producción vuelve a usarse como forma de validar una dirección de
|
||||||
|
> diseño; ver el proceso corregido en §8.
|
||||||
|
> 2. **Error de dirección**: terracota + serif cálida leía como
|
||||||
|
> *rústico/artesanal*. Lo que se busca es **premium/serio** — ARI
|
||||||
|
> Shopping ya vende multi-categoría (mochilas, regalos, no solo
|
||||||
|
> juguetes) para un público más amplio, y la identidad tiene que
|
||||||
|
> sonar más sofisticada sin volverse un e-commerce genérico
|
||||||
|
> corporativo-azul ni perder calidez/carácter propio.
|
||||||
|
>
|
||||||
|
> §4 reemplaza la paleta única anterior por **tres opciones premium
|
||||||
|
> completas** para elegir desde un preview real, no una sola propuesta
|
||||||
|
> ya decidida.
|
||||||
|
|
||||||
|
> Alcance (sin cambios): **solo visual** (paleta, tipografía, radios,
|
||||||
|
> densidad) — ARI Shopping sigue siendo la marca, el catálogo sigue
|
||||||
|
> siendo el mismo. No se toca taxonomía, backend, ni la estructura de
|
||||||
|
> navegación ya construida en el ADR 0005 — se re-expresa visualmente
|
||||||
|
> sobre esa misma estructura.
|
||||||
|
|
||||||
|
## 1. Resumen de la auditoría del estado actual
|
||||||
|
|
||||||
|
(Sin cambios — sigue siendo la descripción real del código, no de la
|
||||||
|
propuesta.)
|
||||||
|
|
||||||
|
El storefront hoy tiene una identidad visual deliberadamente lúdica,
|
||||||
|
tipo "juguetería": paleta arcoíris de alta saturación
|
||||||
|
(`--color-ari-cyan/magenta/yellow`, gradientes `cyan→violet→fuchsia`),
|
||||||
|
`rounded-full` en casi todo, `font-black`/`font-extrabold` como
|
||||||
|
default en título, precio, botones y texto secundario por igual. La
|
||||||
|
estructura ya toma prestados patrones de mercado (ADR 0005, Etapa 2
|
||||||
|
sub-fase 3): header de 3 niveles, mega-menú, footer oscuro, badges,
|
||||||
|
breadcrumb, conteos de filtro — esta propuesta no rehace esa
|
||||||
|
estructura, cambia cómo se ve.
|
||||||
|
|
||||||
|
## 2. Principios de UX a adoptar (funcionales, no estéticos)
|
||||||
|
|
||||||
|
(Sin cambios respecto a la revisión anterior.)
|
||||||
|
|
||||||
|
| Principio | Por qué funciona | Cómo se aplica sin copiar estética |
|
||||||
|
|---|---|---|
|
||||||
|
| Jerarquía visual clara precio/CTA/info secundaria | Si todo pesa igual, el ojo no sabe dónde mirar primero | Precio y CTA son los únicos elementos con peso reservado; categoría/SKU bajan a texto liviano |
|
||||||
|
| Alta densidad sin saturación | Más información real visible compite mejor que decoración vacía | Los gradientes decorativos se liberan para mostrar señales reales del producto |
|
||||||
|
| Señales de confianza visibles | Urgencia de inventario, garantías, envíos leídos como información seria | Se conservan/refuerzan elementos reales ya existentes ("Quedan N", `TrustBar`) en colores serios |
|
||||||
|
| Contraste suficiente para escaneo rápido | Un usuario escanea, no lee | Cada par de color se valida contra WCAG AA con números reales, no se asume |
|
||||||
|
|
||||||
|
## 3. Por qué no clonar la estética de un competidor
|
||||||
|
|
||||||
|
(Sin cambios — sigue vigente independientemente de qué opción de §4
|
||||||
|
se elija.)
|
||||||
|
|
||||||
|
## 4. Tres opciones premium completas
|
||||||
|
|
||||||
|
Las tres evitan por igual: azul corporativo genérico, la paleta
|
||||||
|
arcoíris actual, y el terracota/serif "rústico" ya descartado. Las
|
||||||
|
tres se apoyan en los mismos principios de §2. Difieren en cuánta
|
||||||
|
calidez vs. formalidad tienen, y en el carácter tipográfico exacto.
|
||||||
|
Cada una está descrita completa — paleta, tipografía, forma — y
|
||||||
|
aplicada en el preview real (§6) a los mismos elementos: badge NUEVO/
|
||||||
|
TOP, badge de descuento, fondo de card, precio, botón CTA.
|
||||||
|
|
||||||
|
### Opción 1 — "Boutique de regalos"
|
||||||
|
|
||||||
|
La más formal de las tres. Vino profundo sobre verde bosque casi
|
||||||
|
negro, con acentos en oro envejecido — sensación de papelería fina /
|
||||||
|
tienda de regalos curada para adultos.
|
||||||
|
|
||||||
|
| Rol | Token | Valor |
|
||||||
|
|---|---|---|
|
||||||
|
| CTA / acción única | `--color-ari-vino` | `#8C2F4B` |
|
||||||
|
| Ancla oscura (header/footer) | `--color-ari-bosque` | `#1B2A22` |
|
||||||
|
| Acento (destacado/confianza) | `--color-ari-oro-viejo` | `#C9A227` |
|
||||||
|
| Fondo general | `--color-ari-marfil` | `#FAF6EF` |
|
||||||
|
| Texto principal | `--color-ari-carbon` | `#241C1A` |
|
||||||
|
| Badge descuento | `--color-ari-vino-oscuro` | `#6E2338` |
|
||||||
|
|
||||||
|
- **Tipografía**: [Playfair Display](https://fonts.google.com/specimen/Playfair+Display)
|
||||||
|
para titulares/precio (serif editorial, dramática, con contraste
|
||||||
|
alto entre trazos gruesos/finos — la más "premium boutique" de las
|
||||||
|
tres) + Inter para cuerpo/UI.
|
||||||
|
- **Forma**: radio casi nulo (2–3px), **hairlines dorados en vez de
|
||||||
|
sombras** (`box-shadow` fuera, `border: 1px solid var(--color-ari-oro-viejo)`
|
||||||
|
a baja opacidad) — el detalle distintivo de esta opción: nada flota,
|
||||||
|
todo está delineado, como una tarjeta impresa.
|
||||||
|
- **Contraste validado**: blanco/bosque 15.8:1, blanco/vino 6.1:1,
|
||||||
|
carbón/marfil 13.1:1 — todos superan AA con margen amplio.
|
||||||
|
|
||||||
|
### Opción 2 — "Atelier cálido"
|
||||||
|
|
||||||
|
Punto medio: sigue siendo premium pero con más calidez que la Opción 1
|
||||||
|
— ciruela en vez de vino (menos "formal/rojo", más "artesanía fina"),
|
||||||
|
cobre en vez de oro.
|
||||||
|
|
||||||
|
| Rol | Token | Valor |
|
||||||
|
|---|---|---|
|
||||||
|
| CTA / acción única | `--color-ari-ciruela` | `#5B3256` |
|
||||||
|
| Ancla oscura | `--color-ari-carbon-oscuro` | `#221D1A` |
|
||||||
|
| Acento | `--color-ari-cobre` | `#B5723A` |
|
||||||
|
| Fondo general | `--color-ari-hueso` | `#F7F1E6` |
|
||||||
|
| Texto principal | `--color-ari-carbon` | `#2A2420` |
|
||||||
|
| Badge descuento | `--color-ari-terracota-oscuro` | `#9C4A2E` |
|
||||||
|
|
||||||
|
- **Tipografía**: [Domine](https://fonts.google.com/specimen/Domine)
|
||||||
|
para titulares/precio (serif con más peso y autoridad que Fraunces,
|
||||||
|
menos "suave/artesanal", sin el dramatismo editorial de Playfair —
|
||||||
|
el punto medio deliberado entre las otras dos opciones) + Inter.
|
||||||
|
- **Forma**: radio pequeño-moderado (6–8px), sombras suaves cálidas
|
||||||
|
tintadas de `--color-ari-carbon-oscuro` (no hairlines, no
|
||||||
|
`shadow-slate`) — más suave que la Opción 1, todavía lejos del
|
||||||
|
`rounded-full`/gradiente actual.
|
||||||
|
- **Contraste validado**: blanco/carbón-oscuro 15.1:1, blanco/ciruela
|
||||||
|
8.9:1, carbón/hueso 11.7:1 — todos superan AA con margen amplio.
|
||||||
|
|
||||||
|
### Opción 3 — "Minimal cálido"
|
||||||
|
|
||||||
|
La más restringida de las tres — casi monocromática, un solo acento
|
||||||
|
de color por composición, mucho blanco. Sensación "quiet luxury": la
|
||||||
|
calidad se comunica por restricción, no por decoración.
|
||||||
|
|
||||||
|
| Rol | Token | Valor |
|
||||||
|
|---|---|---|
|
||||||
|
| CTA / acción única | `--color-ari-ambar` | `#B8842E` |
|
||||||
|
| Ancla oscura | `--color-ari-negro-calido` | `#1C1917` |
|
||||||
|
| Acento secundario | `--color-ari-oliva` | `#5C6650` |
|
||||||
|
| Fondo general | `--color-ari-blanco-calido` | `#FAF8F5` |
|
||||||
|
| Texto principal | `--color-ari-negro-calido` | `#1C1917` |
|
||||||
|
| Badge descuento | `--color-ari-negro-calido` con texto ámbar | `#1C1917` / `#B8842E` |
|
||||||
|
|
||||||
|
- **Tipografía**: [Newsreader](https://fonts.google.com/specimen/Newsreader)
|
||||||
|
itálica para precio/titulares (serif moderna diseñada para lectura,
|
||||||
|
calmada, "editorial silencioso" — la menos decorativa de las tres)
|
||||||
|
+ Inter.
|
||||||
|
- **Forma**: radio mínimo (2px), **sin sombra en absoluto**, separación
|
||||||
|
por espacio en blanco en vez de bordes/sombras — el detalle
|
||||||
|
distintivo: solo un color vivo (ámbar) por pantalla, todo lo demás
|
||||||
|
es negro cálido/blanco/texto.
|
||||||
|
- **Contraste validado**: negro-cálido/blanco-cálido 17.9:1, blanco/
|
||||||
|
ámbar 3.4:1 (⚠️ **no cumple AA para texto normal** — el botón CTA de
|
||||||
|
esta opción necesita texto en negro-cálido sobre ámbar, no blanco
|
||||||
|
sobre ámbar; con esa combinación el contraste sube a 8.1:1, sí
|
||||||
|
cumple), oliva/blanco-cálido 4.9:1.
|
||||||
|
|
||||||
|
## 5. Tabla comparativa rápida
|
||||||
|
|
||||||
|
| | Opción 1 · Boutique | Opción 2 · Atelier | Opción 3 · Minimal |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CTA | Vino `#8C2F4B` | Ciruela `#5B3256` | Ámbar `#B8842E` |
|
||||||
|
| Ancla oscura | Bosque `#1B2A22` | Carbón oscuro `#221D1A` | Negro cálido `#1C1917` |
|
||||||
|
| Acento | Oro viejo `#C9A227` | Cobre `#B5723A` | Oliva `#5C6650` |
|
||||||
|
| Fondo | Marfil `#FAF6EF` | Hueso `#F7F1E6` | Blanco cálido `#FAF8F5` |
|
||||||
|
| Display | Playfair Display | Domine | Newsreader (itálica) |
|
||||||
|
| Radio | 2–3px | 6–8px | 2px |
|
||||||
|
| Elevación | Hairline dorado, sin sombra | Sombra suave tintada | Sin sombra, solo espacio |
|
||||||
|
| Personalidad | Formal, editorial, "boutique" | Cálido, artesanal-fino, punto medio | Restringido, "quiet luxury" |
|
||||||
|
|
||||||
|
## 6. Decisión final: Opción 2 — "Atelier cálido"
|
||||||
|
|
||||||
|
El usuario delegó la elección final entre las 3 opciones de §4 en vez
|
||||||
|
de revisarlas una por una. Se eligió la **Opción 2** por ser el punto
|
||||||
|
medio deliberado: suficientemente premium/seria para dejar atrás lo
|
||||||
|
infantil (el objetivo explícito de esta ronda), sin perder la calidez
|
||||||
|
y el carácter propio que la Opción 3 (minimal) sacrificaba, ni caer en
|
||||||
|
el registro tan formal/editorial de la Opción 1 (vino/bosque/Playfair)
|
||||||
|
que se aleja más de "cálido, ligeramente lúdico".
|
||||||
|
|
||||||
|
## 7. Decisión de proceso (corregida)
|
||||||
|
|
||||||
|
Se descartó construir un `/design-preview` de comparación de las 3
|
||||||
|
opciones — el usuario pidió explícitamente decidir directo y revisar
|
||||||
|
el resultado ya aplicado, no volver a mediar cada paso. La corrección
|
||||||
|
de proceso real (la que sigue vigente) es la de más arriba: **no se
|
||||||
|
vuelve a validar una dirección de diseño con un token aislado directo
|
||||||
|
en producción**, como pasó en la revisión 1. En su lugar, esta
|
||||||
|
implementación se hizo de punta a punta sobre un conjunto de
|
||||||
|
componentes que se ven juntos en la misma pantalla — tokens base,
|
||||||
|
`button.tsx`, `badge.tsx`, `product-card.tsx` y los estados
|
||||||
|
interactivos de `catalog-client.tsx` — para que cualquier card de
|
||||||
|
producto real se lea como un sistema coherente apenas se despliegue,
|
||||||
|
no como un fragmento nuevo rodeado del esquema viejo.
|
||||||
|
|
||||||
|
Header, footer, home y página de producto/drawers (Sub-fases 2, 4 y 5
|
||||||
|
del plan original) **quedan pendientes** de una siguiente pasada —
|
||||||
|
mientras tanto se van a ver con la paleta anterior, a propósito, no
|
||||||
|
por un olvido.
|
||||||
|
|
||||||
|
## 8. Consecuencias
|
||||||
|
|
||||||
|
- Producto de esta pasada: catálogo (`/catalog`) y sus cards quedan
|
||||||
|
con una identidad completa y coherente entre sí. Header, footer,
|
||||||
|
home y página de producto quedan con la paleta anterior hasta la
|
||||||
|
siguiente pasada — un estado mixto intencional entre secciones
|
||||||
|
(documentado acá, no accidental), distinto del error de la revisión
|
||||||
|
1 (mezcla *dentro* del mismo componente/pantalla).
|
||||||
|
- El catálogo, backend, taxonomía y flujo de búsqueda/facetas (ADR
|
||||||
|
0006) quedan completamente intactos.
|
||||||
|
- Trabajo puramente de frontend: no requiere cambios en
|
||||||
|
`commerce-backend`, `platform-infra`, ni CI nuevo.
|
||||||
|
- Al redefinir directamente las variants existentes de `button.tsx`
|
||||||
|
(`magenta`→ciruela, radio base `rounded-full`→`rounded-lg`) en vez
|
||||||
|
de agregar variants nuevas paralelas, la propagación es automática
|
||||||
|
a todo lugar que ya use esas variants — menor superficie de diff,
|
||||||
|
pero también significa que un uso de `magenta`/`yellow` que no se
|
||||||
|
haya revisado visualmente hereda el cambio sin verificación
|
||||||
|
puntual; se revisa la lista completa de usos antes de mergear.
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# ADR 0008 — Identidad visual "Navy & Coral" para ARI Shopping
|
||||||
|
|
||||||
|
**Estado:** aceptada, con enmienda de paleta (ver §8) — sub-fases 1 a 4
|
||||||
|
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")
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
|
Este es el tercer intento de identidad visual propia para ARI Shopping,
|
||||||
|
después de dos ciclos de implementar-y-descartar en producción (ver
|
||||||
|
cronología en §2). El costo real de esos dos ciclos no fue el tiempo
|
||||||
|
de escribir el código, fue el tiempo de verlo mal en producción antes
|
||||||
|
de poder juzgarlo — y en ambos casos el veredicto real no era sobre la
|
||||||
|
paleta en sí, sino sobre verla mezclada con partes del sitio que
|
||||||
|
todavía no se habían tocado.
|
||||||
|
|
||||||
|
## 2. Cronología completa
|
||||||
|
|
||||||
|
1. **Arcoíris original** — la identidad "juguetería" con la que arrancó
|
||||||
|
el storefront: paleta de alta saturación (cian/violeta/fucsia/ámbar/
|
||||||
|
amarillo/lima), `rounded-full` en casi todo, `font-black` como
|
||||||
|
default. Nunca fue una decisión de marca, era el estado por defecto
|
||||||
|
del scaffold inicial.
|
||||||
|
2. **Intento de clon de Amazon** (descartado antes de implementarse) —
|
||||||
|
la primera versión de ADR 0007 proponía clonar la estética visual de
|
||||||
|
un competidor directamente. Se corrigió a adoptar *principios* de UX
|
||||||
|
(jerarquía, densidad, señales de confianza), no la estética de nadie
|
||||||
|
— ver la memoria de este cambio de dirección.
|
||||||
|
3. **Terracota/espresso/pino/crema + Fraunces** ("cálido, confiable,
|
||||||
|
ligeramente lúdico") — se implementó como *smoke test* aislado en
|
||||||
|
producción (v1.0.176: solo el botón CTA de `ProductCard` en
|
||||||
|
terracota + precio en Fraunces, todo lo demás sin tocar) y se
|
||||||
|
revirtió. Dos motivos reales: (a) un token aislado rodeado del resto
|
||||||
|
del esquema viejo (badges fucsia/amarillo neón) no es evaluable — el
|
||||||
|
choque visual no era "la dirección de marca", era la mezcla
|
||||||
|
incompleta; (b) terracota + serif cálida leía como *rústico/
|
||||||
|
artesanal*, y el objetivo era premium/serio.
|
||||||
|
4. **Ciruela/carbón/cobre "Atelier cálido"** (ADR 0007, Opción 2 de 3) —
|
||||||
|
esta vez la implementación cubrió un conjunto completo de
|
||||||
|
componentes que se ven juntos en la misma pantalla (tokens base,
|
||||||
|
`button.tsx`, `badge.tsx`, `product-card.tsx`,
|
||||||
|
`catalog-client.tsx`), evitando el error de proceso del punto
|
||||||
|
anterior. Aun así, header, footer, home y página de producto/drawers
|
||||||
|
quedaron deliberadamente pendientes de una siguiente pasada (ADR
|
||||||
|
0007 §7). Resultado: se descartó — no convenció combinada con el
|
||||||
|
resto del sitio sin cambiar. El catálogo aislado se veía coherente,
|
||||||
|
pero el sitio completo (con el header/footer viejos alrededor)
|
||||||
|
seguía sin leerse como una marca terminada.
|
||||||
|
5. **"Navy & Coral"** (esta dirección, final) — marino/coral/coral
|
||||||
|
claro + Poppins para momentos de marca. Ver especificación completa
|
||||||
|
en §4.
|
||||||
|
|
||||||
|
## 3. Mejora de proceso: validar barato antes de implementar
|
||||||
|
|
||||||
|
A diferencia de los dos intentos anteriores, esta dirección se validó
|
||||||
|
visualmente con el usuario mediante mockups de referencia simulados
|
||||||
|
**fuera de este entorno, antes de tocar código real**. Ningún ciclo de
|
||||||
|
implementación se gastó en generar opciones que después se
|
||||||
|
descartarían por gusto — el código de esta ADR se escribe ya sobre una
|
||||||
|
dirección aprobada.
|
||||||
|
|
||||||
|
Esto vale la pena dejarlo como práctica para el playbook de diseño de
|
||||||
|
este proyecto: cuando el costo de deshacer una implementación en
|
||||||
|
producción es alto (como pasó en los puntos 3 y 4 de §2), vale la pena
|
||||||
|
invertir primero en una validación visual barata y rápida (mockup,
|
||||||
|
preview, captura) antes de comprometer ciclos de implementación real,
|
||||||
|
en vez de decidir la dirección *mientras* se implementa.
|
||||||
|
|
||||||
|
## 4. Especificación de marca
|
||||||
|
|
||||||
|
### Paleta
|
||||||
|
|
||||||
|
| Rol | Token | Valor |
|
||||||
|
|---|---|---|
|
||||||
|
| Ancla (header, footer, badge "Nuevo", fondo de hero) | `--color-ari-marino` | `#122844` |
|
||||||
|
| Acento único de acción (CTA "Agregar al carrito", precio destacado) | `--color-ari-coral` | `#D85A30` |
|
||||||
|
| Acento secundario (subtítulos sobre marino, ala inferior del isotipo) | `--color-ari-coral-claro` | `#F0997B` |
|
||||||
|
| Fondo general | — | `#FFFFFF` (blanco puro; reemplaza `--color-ari-hueso`) |
|
||||||
|
| Texto secundario / bordes | escala `slate-*` ya usada en el proyecto | sin token nuevo |
|
||||||
|
|
||||||
|
### Tipografía
|
||||||
|
|
||||||
|
- **Poppins, peso 700** — solo para los "momentos de marca": wordmark
|
||||||
|
del logo, títulos de sección (`h1`/`h2`), precio de producto, texto
|
||||||
|
de CTA. Es el único peso que se carga.
|
||||||
|
- **Inter** (ya integrada) se mantiene para body/UI/navegación — no
|
||||||
|
todo el sitio pasa a Poppins.
|
||||||
|
- Domine (Opción 2 de ADR 0007) se elimina por completo, incluida su
|
||||||
|
carga vía `next/font`.
|
||||||
|
|
||||||
|
### Isotipo
|
||||||
|
|
||||||
|
Mariposa geométrica simplificada, construida con 4 elipses (no el
|
||||||
|
filigrana intrincado del logo original, que se perdía en tamaños
|
||||||
|
chicos):
|
||||||
|
|
||||||
|
| Elemento | Parámetros | Color |
|
||||||
|
|---|---|---|
|
||||||
|
| Ala superior izquierda | elipse `rx=9 ry=7`, `cx=10 cy=8`, rotada -18° | `#D85A30` |
|
||||||
|
| Ala superior derecha | elipse `rx=9 ry=7`, `cx=24 cy=8`, rotada 18° | `#D85A30` |
|
||||||
|
| Ala inferior izquierda | elipse `rx=6 ry=5`, `cx=12 cy=16`, rotada -10° | `#F0997B` |
|
||||||
|
| Ala inferior derecha | elipse `rx=6 ry=5`, `cx=22 cy=16`, rotada 10° | `#F0997B` |
|
||||||
|
| Cuerpo | rectángulo `x=16 y=3 width=2 height=18`, redondeado | `#122844` |
|
||||||
|
|
||||||
|
Coordenadas base sobre `viewBox 34x24`, sobre fondo circular marino.
|
||||||
|
Wordmark "ARI" (Poppins 700, blanco) + "shopping" debajo (Poppins 700,
|
||||||
|
`#F0997B`, más chico, letter-spacing amplio).
|
||||||
|
|
||||||
|
Assets: `public/images/brand/logo-ari.svg` (lockup completo,
|
||||||
|
reemplaza `logo-ari.png`) y `app/icon.svg` (versión reducida — solo
|
||||||
|
círculo + mariposa, sin wordmark — favicon/app icon vía la convención
|
||||||
|
de Next.js App Router).
|
||||||
|
|
||||||
|
## 5. Hallazgo: causa raíz del estado mixto anterior
|
||||||
|
|
||||||
|
Al auditar `navbar.tsx` para esta implementación se encontró que,
|
||||||
|
desde el arranque de este proyecto de reskin, nunca se había tocado:
|
||||||
|
una barra superior con gradiente `from-amber-300 via-yellow-200
|
||||||
|
to-lime-200` y una línea decorativa `from-cyan-400 via-violet-500
|
||||||
|
to-fuchsia-500`, ambas del esquema arcoíris original (punto 1 de §2).
|
||||||
|
Esto confirma que el "estado mixto" visto en producción durante ADR
|
||||||
|
0007 no era solo "header pendiente de una pasada futura" en abstracto
|
||||||
|
— eran restos concretos y nunca inventariados del esquema anterior,
|
||||||
|
conviviendo con cada iteración de paleta desde entonces sin que nadie
|
||||||
|
los marcara como deuda. Se eliminan en la sub-fase 2 de esta
|
||||||
|
implementación.
|
||||||
|
|
||||||
|
## 6. Plan de implementación
|
||||||
|
|
||||||
|
Por sub-fases, cada una con rama `fix/*`, diff mostrado y PR sin
|
||||||
|
mergear hasta confirmación explícita del usuario. Cada sub-fase debe
|
||||||
|
dejar el sitio en un estado internamente coherente (no fragmentos
|
||||||
|
aislados en producción — la lección de los puntos 3 y 4 de §2) y debe
|
||||||
|
confirmar con evidencia real (no supuesta) que el flujo de
|
||||||
|
auto-refresh desde Medusa Admin (precio/descripción/imagen) sigue
|
||||||
|
funcionando.
|
||||||
|
|
||||||
|
1. Tokens base + logo (`globals.css`, `logo-ari.svg`, favicon).
|
||||||
|
2. Header + footer (`navbar.tsx` — incluida la limpieza de §5 —,
|
||||||
|
`footer.tsx`, logo aplicado).
|
||||||
|
3. Product card + grid de catálogo + badges.
|
||||||
|
4. Home (hero, tiles de categoría, secciones).
|
||||||
|
5. Página de producto + drawers de carrito/wishlist.
|
||||||
|
|
||||||
|
### Nota de implementación: alias de compatibilidad de tokens
|
||||||
|
|
||||||
|
La sub-fase 1 solo toca `globals.css`/`layout.tsx`/assets — no
|
||||||
|
`catalog-client.tsx`, `product-card.tsx` ni `button.tsx`, que hoy
|
||||||
|
siguen usando los nombres de token de ADR 0007
|
||||||
|
(`ari-ciruela`, `ari-carbon-oscuro`, `ari-cobre`, `ari-carbon`,
|
||||||
|
`ari-terracota-oscuro`, `ari-hueso`). Para no dejar esos usos sin
|
||||||
|
estilo entre sub-fases, `globals.css` define esos nombres como alias
|
||||||
|
temporales resueltos ya contra la paleta nueva. Cada sub-fase que
|
||||||
|
toque esos archivos reemplaza el nombre de clase por el token
|
||||||
|
definitivo; los alias se eliminan por completo al cerrar la sub-fase
|
||||||
|
5.
|
||||||
|
|
||||||
|
## 7. Consecuencias
|
||||||
|
|
||||||
|
- El sitio completo adopta la paleta nueva en un solo despliegue desde
|
||||||
|
la sub-fase 1 (vía los alias de §6), incluso antes de que cada
|
||||||
|
componente migre al nombre de token definitivo — evita repetir el
|
||||||
|
error de "token aislado rodeado del esquema viejo" de los puntos 3 y
|
||||||
|
4 de §2.
|
||||||
|
- Domine y su carga de fuente se eliminan; Poppins se agrega mínimo
|
||||||
|
(solo peso 700).
|
||||||
|
- Trabajo puramente de frontend: no requiere cambios en
|
||||||
|
`commerce-backend`, `platform-infra`, ni CI nuevo.
|
||||||
|
- `docs-portal` se actualiza con capturas/descripción del resultado
|
||||||
|
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ó.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# ADR 0009 — Checkout real (pago en línea + envíos) para ARI Shopping
|
||||||
|
|
||||||
|
**Estado:** aceptada — proveedor de pago decidido (PayU, §3); la
|
||||||
|
implementación real terminó usando ePayco en vez de PayU (PayU LatAm
|
||||||
|
quedó inviable, Rapyd se evaluó y se descartó sin código) y sumó
|
||||||
|
EnvíoClick para envío calculado — ver [ADR 0010](./0010-pago-envio-reales-y-storefront.md)
|
||||||
|
para el pivote completo y el estado final desplegado
|
||||||
|
**Fecha:** 2026-08-28
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El carrito hoy no completa una transacción real: arma el pedido en el
|
||||||
|
cliente y lo cierra con un botón "Continuar por WhatsApp"
|
||||||
|
(`components/layout/navbar.tsx`, `mobile-add-to-cart.tsx`) — un
|
||||||
|
placeholder intencional, no un bug. El diálogo de cuenta ya lo admite
|
||||||
|
explícitamente ("El inicio de sesión quedará conectado al backend
|
||||||
|
headless. Mientras tanto..."). Ni checkout ni auth de clientes tienen
|
||||||
|
ADR previo ni intento de implementación en el historial de git de
|
||||||
|
ningún repo del lab — este es terreno nuevo.
|
||||||
|
|
||||||
|
Investigación previa a este ADR (2026-08-28, confirmado contra código
|
||||||
|
real y documentación oficial vigente, no memoria de entrenamiento —
|
||||||
|
misma disciplina que ADR 0006):
|
||||||
|
|
||||||
|
- **Pagos:** `commerce-backend/medusa-config.ts` no registra ningún
|
||||||
|
provider de pago — el único módulo configurado hoy es
|
||||||
|
`@medusajs/medusa/file` (storage S3).
|
||||||
|
- **Envíos:** existe una sola región ("Colombia", COP), creada a mano
|
||||||
|
vía Admin API durante el incidente de precios de agosto
|
||||||
|
(`incidente-medusa-precios-region-id-2026-08.md`), no vía ningún
|
||||||
|
script de seed — no existe ningún script de seed en el repo. **No
|
||||||
|
hay ninguna shipping option configurada en ningún lado.** Sin esto
|
||||||
|
el checkout no puede completarse aunque el pago ya funcione — es un
|
||||||
|
prerequisito invisible, independiente del proveedor de pago elegido.
|
||||||
|
- **Auth:** cero código de cliente/login en todo el storefront (grep
|
||||||
|
sobre `lib/`, `app/`, `components/` sin resultados para
|
||||||
|
`customer`/`login`/`useAuth`/`/store/customers`). El módulo de
|
||||||
|
auth/customer de Medusa viene incluido en el core — falta
|
||||||
|
enteramente el lado del storefront, no el backend. **Medusa soporta
|
||||||
|
checkout como invitado**, así que auth no bloquea checkout y puede
|
||||||
|
secuenciarse por separado — fuera de alcance de este ADR.
|
||||||
|
|
||||||
|
## 1. Flujo de checkout real en Medusa v2 (Store API)
|
||||||
|
|
||||||
|
Confirmado contra `docs.medusajs.com` (2026-08-28): el flujo son 7
|
||||||
|
pasos encadenados — crear carrito → agregar items → dirección de envío
|
||||||
|
→ **seleccionar método de envío** (`POST` a la ruta de shipping method
|
||||||
|
del carrito, requiere que ya exista al menos una shipping option) →
|
||||||
|
payment collection → payment session → completar orden. Ningún paso
|
||||||
|
es opcional; el paso de método de envío es exactamente el que hoy no
|
||||||
|
tiene ninguna shipping option que ofrecer.
|
||||||
|
|
||||||
|
## 2. Estado real de soporte de pago en Medusa 2.18.0
|
||||||
|
|
||||||
|
- **Stripe es de primera clase** (`@medusajs/medusa/payment-stripe`,
|
||||||
|
incluido en el core, no un plugin de terceros — a diferencia de la
|
||||||
|
situación de Meilisearch en ADR 0006). Configuración mínima
|
||||||
|
verificada:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/payment",
|
||||||
|
options: {
|
||||||
|
providers: [{
|
||||||
|
resolve: "@medusajs/medusa/payment-stripe",
|
||||||
|
id: "stripe",
|
||||||
|
options: { apiKey: process.env.STRIPE_API_KEY },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Env vars: `STRIPE_API_KEY` (siempre) y `STRIPE_WEBHOOK_SECRET`
|
||||||
|
(obligatorio en producción). Tras configurar, hay que habilitar el
|
||||||
|
provider **en la región** desde el Admin — un paso manual aparte,
|
||||||
|
igual que ya pasó con la región misma.
|
||||||
|
|
||||||
|
- **Bloqueador real de negocio, no técnico: Stripe no acepta cuentas
|
||||||
|
de comercio registradas en Colombia.** Confirmado contra
|
||||||
|
`stripe.com/global` (2026-08-28) — Brasil y México sí están en la
|
||||||
|
lista de países soportados para abrir cuenta, Colombia no aparece.
|
||||||
|
La única vía sería registrar una entidad de negocio en un país
|
||||||
|
soportado, fuera de alcance realista para este lab. Esto invalida a
|
||||||
|
Stripe como opción pese a ser la integración técnicamente más
|
||||||
|
simple y mejor documentada.
|
||||||
|
- **Alternativas con presencia real en Colombia y algún plugin de
|
||||||
|
Medusa existente:**
|
||||||
|
- **PayU** (`@tax1driver/medusa-payu`): el propio repo declara
|
||||||
|
compatibilidad explícita con Medusa `v2.4.0+` — cubre nuestra
|
||||||
|
versión (2.18.0). Webhook auto-registrado en `/hooks/payment/payu`.
|
||||||
|
- **Wompi** (`medusa-payment-wompi-col`, respaldado por Bancolombia,
|
||||||
|
liquidación directa en COP): revisé el repo directamente
|
||||||
|
(2026-08-28) y **no documenta contra qué versión de Medusa se
|
||||||
|
probó, ni tiene releases/actividad reciente visible** — mayor
|
||||||
|
riesgo de compatibilidad no verificada que PayU, aunque el
|
||||||
|
encaje de negocio (banco local, moneda nativa) sea el más
|
||||||
|
directo. Antes de elegirlo habría que repetir el nivel de
|
||||||
|
verificación que ADR 0006 le hizo al plugin de Meilisearch: leer
|
||||||
|
el código fuente real, no confiar en el README.
|
||||||
|
- **Riesgo de red ya conocido, no nuevo:** cualquier webhook de pago
|
||||||
|
entra por la misma cadena Cloudflare → NPM → Traefik que ya causó
|
||||||
|
un incidente real con otro servicio
|
||||||
|
(`incidente-cloudflared-504-quic-2026-08.md`). Validar la entrega de
|
||||||
|
webhooks en vivo antes de confiar en el flujo, no asumir que
|
||||||
|
"funciona igual que todo lo demás".
|
||||||
|
|
||||||
|
## 3. Decisión de proveedor — PayU
|
||||||
|
|
||||||
|
Dato de negocio real que decidió esto (aportado por el usuario,
|
||||||
|
2026-08-28): las cuentas bancarias disponibles son Davivienda, Lulo
|
||||||
|
Bank y Banco de Bogotá — **ninguna es Bancolombia ni Nequi**.
|
||||||
|
|
||||||
|
Verificado contra fuentes reales el mismo día (no memoria):
|
||||||
|
|
||||||
|
- **Wompi exige cuenta Bancolombia o, como mínimo, cuenta Nequi para
|
||||||
|
poder registrarse como comercio** (modelo Agregador) — confirmado
|
||||||
|
contra el reglamento de comercios de Wompi y su centro de soporte.
|
||||||
|
La liquidación de ventas sí puede ir después a cualquier banco
|
||||||
|
colombiano, pero la puerta de entrada al registro sigue siendo
|
||||||
|
Bancolombia/Nequi. Con los bancos disponibles hoy, esto exigiría
|
||||||
|
abrir una cuenta Nequi nueva solo para poder registrarse — paso
|
||||||
|
extra evitable.
|
||||||
|
- **PayU solo exige "cuenta bancaria colombiana a nombre del
|
||||||
|
comercio"**, sin banco específico — Davivienda, Lulo Bank o Banco de
|
||||||
|
Bogotá califican igual, sin pasos previos. Además ya soporta **PSE**
|
||||||
|
(transferencia desde cualquier banco colombiano del cliente, sin
|
||||||
|
relación con el banco del comercio), **Nequi** y **Daviplata** como
|
||||||
|
métodos de pago para los clientes vía redirección — cubre exactamente
|
||||||
|
los métodos que se pidieron (PSE + Nequi "y demás bancos") sin que
|
||||||
|
la cuenta bancaria propia del comercio sea Bancolombia.
|
||||||
|
|
||||||
|
**Decisión:** PayU como proveedor de pago (negocio) se mantiene. La vía
|
||||||
|
técnica cambió el 2026-08-29 — ver nota de implementación al final de
|
||||||
|
este documento: ningún plugin comunitario de Medusa sirve para PayU
|
||||||
|
LatAm, hace falta un módulo de pago custom. Wompi queda descartado por
|
||||||
|
el requisito de registro Bancolombia/Nequi, no por limitación técnica.
|
||||||
|
|
||||||
|
**Pendiente antes de implementar (no resoluble en este ADR):**
|
||||||
|
|
||||||
|
- Registro real de comercio en PayU (persona natural o jurídica —
|
||||||
|
jurídica pide NIT, representante legal y estados financieros
|
||||||
|
básicos) y credenciales reales (`apiLogin`, `apiKey`, `merchantId`,
|
||||||
|
`accountId` — terminología real de la API de PayU LatAm, ver nota de
|
||||||
|
implementación) — nunca generadas ni asumidas por mí.
|
||||||
|
- Escribir el módulo de pago custom para PayU LatAm (no instalar un
|
||||||
|
plugin — no existe ninguno real para esta API, ver nota de
|
||||||
|
implementación).
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| **Stripe** | Integración core de Medusa, mejor documentada, configuración mínima | No acepta comercios registrados en Colombia — bloqueador de negocio, no técnico |
|
||||||
|
| **PayU** — **elegido** (proveedor de negocio; integración técnica ahora es custom, ver nota de implementación) | Solo exige cuenta bancaria colombiana a nombre del comercio, sin banco específico (encaja con Davivienda/Lulo Bank/Banco de Bogotá); soporta PSE + Nequi + Daviplata para el cliente sin importar el banco del comercio | Ningún plugin de Medusa real sirve para su API LatAm (`apiLogin`/`apiKey`/`accountId`) — los dos paquetes que parecían encajar por nombre son en realidad para PayU Polonia/CEE (`clientId`/`posId`/`secondKey`), uno de ellos archivado; requiere módulo de pago custom |
|
||||||
|
| **Wompi** (`medusa-payment-wompi-col`) | Liquidación directa en COP vía Bancolombia | Exige cuenta Bancolombia o, como mínimo, Nequi solo para **registrarse** como comercio — ninguno de los bancos disponibles (Davivienda/Lulo Bank/Banco de Bogotá) califica sin abrir una cuenta nueva; además, sin versión de Medusa documentada ni actividad reciente visible en el plugin |
|
||||||
|
| **Seguir con WhatsApp** (no pago en línea) | Cero riesgo nuevo, cero dependencia externa | No resuelve el objetivo real (transacción completa sin intervención manual); no es checkout, es lo que ya existe hoy |
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Las shipping options son un prerequisito independiente del proveedor
|
||||||
|
y deben configurarse primero — sin ellas ningún flujo de checkout
|
||||||
|
completa, sea cual sea el pago.
|
||||||
|
- El pago de PayU se implementa como módulo custom (`AbstractPaymentProvider`
|
||||||
|
de Medusa) contra la API real de PayU LatAm, no como instalación de
|
||||||
|
un plugin de terceros — más superficie propia para mantener, mismo
|
||||||
|
patrón de esfuerzo que ya se identificó para EnvíoClick en §2.
|
||||||
|
- Los webhooks de pago (a definir en el módulo custom, apuntando a la
|
||||||
|
URL de notificación real de PayU LatAm) deben validarse en vivo
|
||||||
|
contra la cadena de red real del lab antes de confiar en el flujo,
|
||||||
|
dado el precedente de `incidente-cloudflared-504-quic-2026-08.md`.
|
||||||
|
- Se acepta como deuda: si en el futuro se abre una cuenta Bancolombia
|
||||||
|
o Nequi, Wompi (liquidación más directa en COP) vuelve a ser una
|
||||||
|
opción válida a reevaluar — no se descarta por limitación técnica,
|
||||||
|
solo por el requisito de registro actual.
|
||||||
|
- Revertir esta decisión implica deshabilitar el provider en la región
|
||||||
|
y volver al botón de WhatsApp — el carrito y el catálogo no cambian
|
||||||
|
de forma, solo el paso final.
|
||||||
|
|
||||||
|
## Nota de implementación (2026-08-29): shipping options creadas
|
||||||
|
|
||||||
|
Bloqueador de §1 resuelto en producción, verificado extremo a extremo
|
||||||
|
contra el Store API real (no solo creado en Admin):
|
||||||
|
|
||||||
|
- Stock location "Bodega principal ARI Shopping" (`sloc_01M15G3D1336V8WQW3VZQRP6AN`).
|
||||||
|
- Fulfillment set + zona de servicio "Colombia" (`geo_zone` tipo país, `co`).
|
||||||
|
- Provider `manual_manual` habilitado en la ubicación.
|
||||||
|
- Shipping option "Envío nacional estándar", tarifa fija $15.000 COP
|
||||||
|
(placeholder de negocio dentro del rango real de mercado
|
||||||
|
$12.000–$18.000 que dio el usuario, no un cálculo por peso/volumen —
|
||||||
|
eso es justamente lo que EnvíoClick resolvería más adelante),
|
||||||
|
vinculada al Default Shipping Profile y a la región Colombia/COP.
|
||||||
|
- **Paso no documentado en ningún lado y que rompía todo en silencio:**
|
||||||
|
la stock location tiene que estar vinculada al Sales Channel
|
||||||
|
(`/admin/stock-locations/{id}/sales-channels`) para que el Store API
|
||||||
|
devuelva la shipping option — sin esto, `GET /store/shipping-options`
|
||||||
|
devolvía `[]` vacío pese a que el fulfillment set, la zona de
|
||||||
|
servicio y el provider ya estaban bien configurados. Confirmado con
|
||||||
|
un carrito de prueba real (dirección `co` + `cart_id`) antes y
|
||||||
|
después del vínculo.
|
||||||
|
|
||||||
|
## Nota de implementación 2 (2026-08-29): corrección — no existe plugin real para PayU LatAm
|
||||||
|
|
||||||
|
Antes de instalar nada se verificó el código/README real de los dos
|
||||||
|
únicos paquetes de Medusa que aparecen buscando "PayU" — misma
|
||||||
|
disciplina que ya aplicó ADR 0006 al plugin de Meilisearch, esta vez
|
||||||
|
con un resultado distinto: **ninguno de los dos sirve.**
|
||||||
|
|
||||||
|
- `@tax1driver/medusa-payu`: opciones de configuración `clientId`,
|
||||||
|
`clientSecret`, `merchantPosId`, `secondKey` — terminología exacta
|
||||||
|
de la API de **PayU Polonia/CEE** (OAuth + `posId`), no de PayU
|
||||||
|
LatAm.
|
||||||
|
- `medusa-plugin-payu` (`rigby-sh/medusa-plugin-payu`): mismo problema
|
||||||
|
(`clientId`, `posId`, sandbox en `secure.snd.payu.com`, dominio de
|
||||||
|
PayU Europa) y además **archivado desde 2025-03-21** — sin
|
||||||
|
mantenimiento.
|
||||||
|
- La API real que se necesita, PayU LatAm
|
||||||
|
(`developers.payulatam.com`, la que cubre Colombia junto con
|
||||||
|
Argentina/Brasil/Chile/México/Panamá/Perú), autentica con
|
||||||
|
`apiLogin`/`apiKey` en el objeto merchant y `accountId` en el
|
||||||
|
objeto order — un modelo de autenticación distinto, no solo nombres
|
||||||
|
de campo distintos.
|
||||||
|
|
||||||
|
"PayU" es una marca compartida por dos productos/API históricamente
|
||||||
|
separados (LatAm vs. Europa/CEE); un paquete que dice "PayU" a secas
|
||||||
|
sin especificar LatAm es, con alta probabilidad, para el producto
|
||||||
|
europeo. **Consecuencia:** la integración de pago se implementa como
|
||||||
|
módulo custom de Medusa (`AbstractPaymentProvider`) contra
|
||||||
|
`developers.payulatam.com` directamente — mismo tipo de esfuerzo que
|
||||||
|
ya se había anticipado para EnvíoClick en §2, no una instalación de
|
||||||
|
plugin como asumía la decisión original de este ADR.
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
# 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 refleja en el storefront**
|
||||||
|
(reportado por el usuario 2026-09-20): subir/editar imágenes de un
|
||||||
|
producto a mano (vs. importación por CSV, que sí funciona) no
|
||||||
|
aparece del lado del storefront. Falta reproducir contra el flujo
|
||||||
|
real de importación manual del admin de Medusa (ver
|
||||||
|
`temp-admin`/`ari-shopping-tienda` en memoria) y confirmar si es
|
||||||
|
caché (ISR de Next, `force-dynamic` ya está en la página de
|
||||||
|
producto — ver más arriba), staleness de Meilisearch (ver memoria
|
||||||
|
`search-inventory-staleness`, mismo síntoma de "el catálogo directo
|
||||||
|
sí, la búsqueda no") o algo específico del pipeline de importación
|
||||||
|
en `commerce-backend`.
|
||||||
|
23. **En mobile, la visualización de imágenes de producto se corta y
|
||||||
|
obliga a scrollear** (reportado por el usuario 2026-09-20, con
|
||||||
|
captura real de `shop.cruzcloud.net` en un producto con múltiples
|
||||||
|
fotos). Toda la clase de bug de overflow por falta de `min-w-0` ya
|
||||||
|
se revisó en vivo en esta sesión y sigue presente en
|
||||||
|
`product-gallery.tsx` y `app/product/[slug]/page.tsx` — sospecha
|
||||||
|
sin confirmar: el modal "Zoom interactivo"
|
||||||
|
(`ProductGallery`/`DialogContent`, `min-h-[70vh]` +
|
||||||
|
`overflow-hidden`) en viewport móvil real, donde la barra de
|
||||||
|
direcciones de Chrome/Safari hace que `100vh` mida más que el área
|
||||||
|
visible real. Pendiente reproducir con un dispositivo real o
|
||||||
|
emulación de `visualViewport` (Playwright con viewport móvil no
|
||||||
|
replica el comportamiento dinámico de la barra de direcciones).
|
||||||
@@ -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.
|
||||||
@@ -85,6 +85,7 @@ nav:
|
|||||||
- Red y exposición: arquitectura/red-y-exposicion.md
|
- Red y exposición: arquitectura/red-y-exposicion.md
|
||||||
- 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
|
||||||
- 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
|
||||||
@@ -92,6 +93,12 @@ nav:
|
|||||||
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
||||||
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
||||||
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
||||||
|
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md
|
||||||
|
- "0007 · Identidad visual propia": decisiones/0007-identidad-visual-propia.md
|
||||||
|
- "0008 · Identidad Navy & Coral": decisiones/0008-identidad-navy-coral.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
|
||||||
- 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
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.162
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.269
|
||||||
# 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.270
|
||||||
|
env:
|
||||||
|
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
|
||||||
|
- name: APP_VERSION
|
||||||
|
value: v1.0.270
|
||||||
|
- 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 { setShippingAddress } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { cartId, ...address } = await request.json();
|
||||||
|
if (!cartId || !address.firstName || !address.address1 || !address.city || !address.phone || !address.email) {
|
||||||
|
return Response.json({ error: "Faltan campos obligatorios de la dirección" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await setShippingAddress(cartId, address);
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,31 @@
|
|||||||
|
import { removeLineItem, updateLineItemQuantity } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
interface RouteParams { params: Promise<{ lineItemId: string }> }
|
||||||
|
|
||||||
|
export async function POST(request: Request, { params }: RouteParams) {
|
||||||
|
const { lineItemId } = await params;
|
||||||
|
const { cartId, quantity } = await request.json();
|
||||||
|
if (!cartId || typeof quantity !== "number") {
|
||||||
|
return Response.json({ error: "cartId y quantity son obligatorios" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await updateLineItemQuantity(cartId, lineItemId, quantity);
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request, { params }: RouteParams) {
|
||||||
|
const { lineItemId } = await params;
|
||||||
|
const cartId = new URL(request.url).searchParams.get("cartId");
|
||||||
|
if (!cartId) {
|
||||||
|
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await removeLineItem(cartId, lineItemId);
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { addLineItem } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { cartId, variantId, quantity } = await request.json();
|
||||||
|
if (!cartId || !variantId) {
|
||||||
|
return Response.json({ error: "cartId y variantId son obligatorios" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await addLineItem(cartId, variantId, quantity ?? 1);
|
||||||
|
return Response.json(cart);
|
||||||
|
} 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,28 @@
|
|||||||
|
import { createCart, getCart } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
// cart-store.ts es "use client" y no puede pegarle directo a Medusa
|
||||||
|
// (MEDUSA_BACKEND_URL es DNS interno del cluster, no resuelve desde el
|
||||||
|
// navegador) -- mismo motivo que /api/search. Nunca cachear: es
|
||||||
|
// estado mutable por sesion, no catalogo.
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const cart = await createCart();
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const cartId = new URL(request.url).searchParams.get("cartId");
|
||||||
|
if (!cartId) {
|
||||||
|
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await getCart(cartId);
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { addShippingMethod } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { cartId, optionId } = await request.json();
|
||||||
|
if (!cartId || !optionId) {
|
||||||
|
return Response.json({ error: "cartId y optionId son obligatorios" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cart = await addShippingMethod(cartId, optionId);
|
||||||
|
return Response.json(cart);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getShippingOptions } from "@/lib/headless/providers/medusa-cart";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const cartId = new URL(request.url).searchParams.get("cartId");
|
||||||
|
if (!cartId) {
|
||||||
|
return Response.json({ error: "cartId es obligatorio" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const options = await getShippingOptions(cartId);
|
||||||
|
return Response.json({ options });
|
||||||
|
} 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,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,45 @@
|
|||||||
|
import { searchWithFacets } from "@/lib/headless/client";
|
||||||
|
import type { ProductFilters } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// PredictiveSearch es un componente cliente y no puede pegarle directo
|
||||||
|
// al provider server-side (MEDUSA_BACKEND_URL es un nombre DNS interno
|
||||||
|
// del cluster, no resuelve desde el navegador) -- esta ruta es el
|
||||||
|
// puente same-origin.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters: ProductFilters = {
|
||||||
|
query: searchParams.get("q") ?? undefined,
|
||||||
|
category: searchParams.get("category") ?? undefined,
|
||||||
|
collection: searchParams.get("collection") ?? undefined,
|
||||||
|
brand: searchParams.get("brand") ?? undefined,
|
||||||
|
ageRange: searchParams.get("ageRange") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const minPrice = searchParams.get("minPrice");
|
||||||
|
if (minPrice) filters.minPrice = Number(minPrice);
|
||||||
|
const maxPrice = searchParams.get("maxPrice");
|
||||||
|
if (maxPrice) filters.maxPrice = Number(maxPrice);
|
||||||
|
const limit = searchParams.get("limit");
|
||||||
|
if (limit) filters.limit = Number(limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await searchWithFacets(filters);
|
||||||
|
// provider actual no implementa searchWithFacets (mock/shopify) --
|
||||||
|
// degradar a vacío, no a 500; el componente cliente cae de nuevo a
|
||||||
|
// su propio filtrado si esta ruta no trae nada útil.
|
||||||
|
return Response.json(
|
||||||
|
result ?? { products: [], facetDistribution: {}, count: 0 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
products: [],
|
||||||
|
facetDistribution: {},
|
||||||
|
count: 0,
|
||||||
|
error: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { CheckoutClient } from "@/components/checkout/checkout-client";
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
return <CheckoutClient />;
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-ari-cyan: #06b6d4;
|
/* ADR 0008 -- paleta final "Azul & Amarillo" (inspirada en Walmart),
|
||||||
--color-ari-magenta: #ec4899;
|
iteracion sobre "Navy & Coral". Ver
|
||||||
--color-ari-yellow: #fde047;
|
docs-portal/docs/decisiones/0008-identidad-navy-coral.md */
|
||||||
--color-ari-blue: #0f172a;
|
--color-ari-marino: #0055B8; /* ancla: header, footer, badge "Nuevo", fondo de hero */
|
||||||
|
--color-ari-coral: #0055B8; /* boton de accion (CTA): azul solido, nunca amarillo */
|
||||||
|
--color-ari-coral-claro: #FFC220; /* acento: badges de oferta/descuento/urgencia, detalles del isotipo -- nunca en botones */
|
||||||
|
|
||||||
|
/* Alias de compatibilidad -- ADR 0007 definio estos nombres y hoy los
|
||||||
|
siguen consumiendo catalog-client.tsx, product-card.tsx y button.tsx
|
||||||
|
(migracion pendiente en las sub-fases 3-5 del ADR 0008). Se resuelven
|
||||||
|
ya contra la paleta nueva para que ningun uso existente quede sin
|
||||||
|
estilo entre sub-fases; se eliminan por completo al cerrar la
|
||||||
|
sub-fase 5. No usar estos alias en código nuevo. */
|
||||||
|
--color-ari-ciruela: var(--color-ari-coral);
|
||||||
|
--color-ari-carbon-oscuro: var(--color-ari-marino);
|
||||||
|
--color-ari-cobre: var(--color-ari-coral-claro);
|
||||||
|
--color-ari-carbon: var(--color-ari-marino);
|
||||||
|
--color-ari-terracota-oscuro: var(--color-ari-coral);
|
||||||
|
--color-ari-hueso: #FFFFFF;
|
||||||
|
|
||||||
|
--font-display: var(--font-poppins), ui-sans-serif, system-ui, sans-serif;
|
||||||
--animate-accordion-down: accordion-down .2s ease-out;
|
--animate-accordion-down: accordion-down .2s ease-out;
|
||||||
--animate-accordion-up: accordion-up .2s ease-out;
|
--animate-accordion-up: accordion-up .2s ease-out;
|
||||||
}
|
}
|
||||||
@@ -15,10 +32,10 @@
|
|||||||
:root { color-scheme: light; }
|
:root { color-scheme: light; }
|
||||||
* { border-color: rgb(226 232 240); }
|
* { border-color: rgb(226 232 240); }
|
||||||
html { scroll-behavior: smooth; }
|
html { scroll-behavior: smooth; }
|
||||||
body { margin: 0; background: #fff; color: #0f172a; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-rendering: optimizeLegibility; }
|
body { margin: 0; background: #FFFFFF; color: var(--color-slate-900); font-family: var(--font-inter), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-rendering: optimizeLegibility; }
|
||||||
button, a { -webkit-tap-highlight-color: transparent; }
|
button, a { -webkit-tap-highlight-color: transparent; }
|
||||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgb(167 139 250 / .55); outline-offset: 3px; }
|
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgb(0 85 184 / .5); outline-offset: 3px; }
|
||||||
::selection { background: #fde047; color: #0f172a; }
|
::selection { background: rgb(0 85 184 / .15); color: var(--color-slate-900); }
|
||||||
|
|
||||||
.content-auto {
|
.content-auto {
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- ADR 0008, paleta final "Azul & Amarillo" (Walmart-inspirada). Version
|
||||||
|
reducida del isotipo para favicon/app icon: solo circulo azul +
|
||||||
|
mariposa, sin wordmark (no cabe legible a este tamano). Convencion de
|
||||||
|
Next.js App Router: este archivo se sirve automaticamente como
|
||||||
|
favicon/app icon. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34" role="img" aria-label="ARI shopping">
|
||||||
|
<circle cx="17" cy="17" r="17" fill="#0055B8"/>
|
||||||
|
<g transform="translate(0 5)">
|
||||||
|
<ellipse cx="10" cy="8" rx="9" ry="7" transform="rotate(-18 10 8)" fill="#FFC220"/>
|
||||||
|
<ellipse cx="24" cy="8" rx="9" ry="7" transform="rotate(18 24 8)" fill="#FFC220"/>
|
||||||
|
<ellipse cx="12" cy="16" rx="6" ry="5" transform="rotate(-10 12 16)" fill="#FFDD73"/>
|
||||||
|
<ellipse cx="22" cy="16" rx="6" ry="5" transform="rotate(10 22 16)" fill="#FFDD73"/>
|
||||||
|
<rect x="16" y="3" width="2" height="18" rx="1" fill="#0055B8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 932 B |
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Inter, Poppins } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { CartDrawer } from "@/components/cart/cart-drawer";
|
import { CartDrawer } from "@/components/cart/cart-drawer";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
@@ -20,9 +21,15 @@ export const metadata: Metadata = {
|
|||||||
openGraph: { title: "ARI Shopping", description: "Tesoros para cada aventura.", type: "website", locale: "es_CO" },
|
openGraph: { title: "ARI Shopping", description: "Tesoros para cada aventura.", type: "website", locale: "es_CO" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
|
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0055B8" };
|
||||||
|
|
||||||
|
// ADR 0008 ("Navy & Coral"): Poppins peso 700 para los "momentos de marca"
|
||||||
|
// (wordmark, h1/h2, precio, CTA) + Inter para body/UI -- solo se carga el
|
||||||
|
// peso 700, es el unico que usa --font-display.
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
|
||||||
|
const poppins = Poppins({ subsets: ["latin"], weight: ["700"], variable: "--font-poppins", display: "swap" });
|
||||||
|
|
||||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
const [products, categories] = await Promise.all([getProducts(), getCategories()]);
|
||||||
return <html lang="es"><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
|
return <html lang="es" className={`${inter.variable} ${poppins.variable}`}><body><Navbar products={products} categories={categories} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,5 @@ import Link from "next/link";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return <section className="grid min-h-[60vh] place-items-center px-4 text-center"><div><span className="text-7xl font-black text-cyan-500">404</span><h1 className="mt-3 text-3xl font-black">Este tesoro no existe</h1><p className="mt-3 text-slate-500">Regresa al catálogo para continuar explorando.</p><Button asChild variant="magenta" className="mt-6"><Link href="/catalog">Ir al catálogo</Link></Button></div></section>;
|
return <section className="grid min-h-[60vh] place-items-center px-4 text-center"><div><span className="text-7xl font-black text-cyan-500">404</span><h1 className="mt-3 text-3xl font-black">Este tesoro no existe</h1><p className="mt-3 text-slate-500">Regresa al catálogo para continuar explorando.</p><Button asChild variant="primary" className="mt-6"><Link href="/catalog">Ir al catálogo</Link></Button></div></section>;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { CategoryTiles } from "@/components/home/category-tiles";
|
import { CategoryTiles } from "@/components/home/category-tiles";
|
||||||
import { HeroBento } from "@/components/home/hero-bento";
|
import { HeroCarousel } from "@/components/home/hero-carousel";
|
||||||
import { TrustBar } from "@/components/home/trust-bar";
|
import { TrustBar } from "@/components/home/trust-bar";
|
||||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,5 +18,34 @@ 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 <><HeroBento /><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-fuchsia-600">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-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 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-yellow-300">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><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
|
// ADR 0010: el banner toma primero productos destacados (curaduria
|
||||||
|
// explicita del admin via el flag "featured"); si no hay ninguno,
|
||||||
|
// cae a los mismos "tesoros" (nuevos/destacados) de la seccion de
|
||||||
|
// abajo para no repetir la logica de seleccion. Se exige ademas
|
||||||
|
// "available" -- encontrado probando en vivo con un navegador real
|
||||||
|
// (Playwright): un producto featured pero sin stock disponible salio
|
||||||
|
// primero en el carrusel con boton "Comprar ahora" hacia una pagina
|
||||||
|
// donde en realidad no se puede comprar.
|
||||||
|
const featuredProducts = products.filter((product) => product.featured && product.available);
|
||||||
|
const bannerProducts = featuredProducts.length > 0 ? featuredProducts : treasures.filter((product) => product.available);
|
||||||
|
return <><HeroCarousel products={bannerProducts} /><TrustBar /><div className="content-auto"><CategoryTiles categories={categories} /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral-claro">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div>{/* No hay herramienta de generacion de imagenes disponible para crear un
|
||||||
|
asset nuevo -- se reconstruye el mismo efecto (fondo punteado +
|
||||||
|
fotos estilo polaroid) con CSS y fotos reales de producto ya
|
||||||
|
existentes, en azul/amarillo de marca en vez del violeta original
|
||||||
|
de promo-backpacks.webp. */}
|
||||||
|
<div className="relative min-h-64 overflow-hidden bg-ari-coral-claro">
|
||||||
|
<div className="absolute inset-0 [background-image:radial-gradient(rgba(0,85,184,0.35)_1.5px,transparent_1.5px)] [background-size:18px_18px]" />
|
||||||
|
<div className="absolute -bottom-12 -left-12 size-52 rounded-full bg-ari-marino" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-3 p-6">
|
||||||
|
<div className="relative size-28 -rotate-6 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:size-32">
|
||||||
|
<Image src="/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp" alt="" fill sizes="128px" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="relative size-28 rotate-3 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:size-32">
|
||||||
|
<Image src="/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp" alt="" fill sizes="128px" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden size-28 -rotate-3 overflow-hidden rounded-xl border-4 border-white bg-white shadow-xl sm:block sm:size-32">
|
||||||
|
<Image src="/images/products/ari-009-mochilas-disney-coleccion-marvel.webp" alt="" fill sizes="128px" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></div></section></>;
|
||||||
}
|
}
|
||||||
@@ -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,7 @@
|
|||||||
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 { ProductCard } from "@/components/product/product-card";
|
||||||
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 +24,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 +43,84 @@ 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-fuchsia-600">{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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -19,3 +19,33 @@ data:
|
|||||||
S3_ENDPOINT: http://minio-svc:9000
|
S3_ENDPOINT: http://minio-svc:9000
|
||||||
S3_REGION: us-east-1
|
S3_REGION: us-east-1
|
||||||
S3_BUCKET: ari-products
|
S3_BUCKET: ari-products
|
||||||
|
|
||||||
|
# Leido por el plugin @rokmohar/medusa-plugin-meilisearch (nombre de
|
||||||
|
# env var del plugin, no del servidor Meilisearch en si -- ver
|
||||||
|
# MEILI_MASTER_KEY en secrets.template.yaml).
|
||||||
|
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"
|
||||||
@@ -7,6 +7,7 @@ resources:
|
|||||||
- redis.yaml
|
- redis.yaml
|
||||||
- minio.yaml
|
- minio.yaml
|
||||||
- minio-bootstrap-job.yaml
|
- minio-bootstrap-job.yaml
|
||||||
|
- meilisearch.yaml
|
||||||
- medusa/
|
- medusa/
|
||||||
- ingress-commerce.yaml
|
- ingress-commerce.yaml
|
||||||
- middleware-commerce-redirect.yaml
|
- middleware-commerce-redirect.yaml
|
||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.127
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
|
||||||
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.127
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: meilisearch-svc
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: commerce-meilisearch
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 7700
|
||||||
|
targetPort: 7700
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: commerce-meilisearch
|
||||||
|
spec:
|
||||||
|
serviceName: meilisearch-svc
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: commerce-meilisearch
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: commerce-meilisearch
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
|
containers:
|
||||||
|
- name: meilisearch
|
||||||
|
image: getmeili/meilisearch:v1.53.1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: MEILI_MASTER_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: commerce-secrets
|
||||||
|
key: MEILI_MASTER_KEY
|
||||||
|
- name: MEILI_ENV
|
||||||
|
value: production
|
||||||
|
# Sin telemetria a un tercero por defecto (misma postura del
|
||||||
|
# resto del namespace: nada de egress no explicado).
|
||||||
|
- name: MEILI_NO_ANALYTICS
|
||||||
|
value: "true"
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 7700
|
||||||
|
# /health no requiere auth (confirmado en vivo) -- sirve tal
|
||||||
|
# cual para las dos probes, igual que minio usa sus propios
|
||||||
|
# endpoints /minio/health/*.
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 6
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 6
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 768Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: meilisearch-data
|
||||||
|
mountPath: /meili_data
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: meilisearch-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
@@ -23,10 +23,34 @@ stringData:
|
|||||||
JWT_SECRET: CAMBIAR
|
JWT_SECRET: CAMBIAR
|
||||||
COOKIE_SECRET: CAMBIAR
|
COOKIE_SECRET: CAMBIAR
|
||||||
|
|
||||||
|
# Master key del servidor Meilisearch (StatefulSet commerce-meilisearch,
|
||||||
|
# env MEILI_MASTER_KEY del propio contenedor getmeili/meilisearch) Y
|
||||||
|
# credencial que usa el plugin de Medusa para autenticarse contra el
|
||||||
|
# -- MISMO valor, dos nombres de secret porque cada lado (servidor vs.
|
||||||
|
# plugin cliente) usa su propia convencion de nombre de env var.
|
||||||
|
MEILI_MASTER_KEY: CAMBIAR
|
||||||
|
MEILISEARCH_API_KEY: CAMBIAR
|
||||||
|
|
||||||
# Debe ser el MISMO valor que REVALIDATE_SECRET en el Secret
|
# Debe ser el MISMO valor que REVALIDATE_SECRET en el Secret
|
||||||
# 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="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
|
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -17,13 +18,6 @@ export function CartDrawer() {
|
|||||||
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
|
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
|
||||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
|
||||||
const checkout = () => {
|
|
||||||
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
|
|
||||||
const message = `Hola Ari Shopping 👋\nQuiero consultar disponibilidad para:\n\n${lines}\n\nNombre:\nCiudad:\nForma de entrega:`;
|
|
||||||
void navigator.clipboard?.writeText(message);
|
|
||||||
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
|
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
|
||||||
<SheetContent>
|
<SheetContent>
|
||||||
@@ -49,7 +43,15 @@ export function CartDrawer() {
|
|||||||
<div className="space-y-4 border-t border-slate-100 p-6">
|
<div className="space-y-4 border-t border-slate-100 p-6">
|
||||||
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
|
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
|
||||||
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
|
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
|
||||||
<Button variant="magenta" size="lg" className="w-full" disabled={!items.length} onClick={checkout}>Continuar por WhatsApp</Button>
|
{/* Button asChild + disabled no funciona sobre un <a> (:disabled
|
||||||
|
es pseudo-clase de elementos de formulario, un Link nunca la
|
||||||
|
cumple) -- se ramifica en vez de confiar en el prop disabled
|
||||||
|
sobre un Link. */}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button asChild variant="primary" size="lg" className="w-full"><Link href="/checkout" onClick={closeCart}>Confirmar pedido</Link></Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" size="lg" className="w-full" disabled>Confirmar pedido</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
|
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles";
|
|||||||
import { ProductCard } from "@/components/product/product-card";
|
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, Product } from "@/lib/headless/types";
|
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
// 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
|
||||||
@@ -156,8 +156,10 @@ export function CatalogClient({
|
|||||||
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
||||||
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
||||||
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
||||||
// resultados habria si se eligiera ese valor.
|
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
|
||||||
const categoryCounts = useMemo(() => {
|
// el provider activo no soporta facetas reales (mock/shopify) o cuando
|
||||||
|
// la busqueda en vivo todavia no respondio.
|
||||||
|
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),
|
||||||
);
|
);
|
||||||
@@ -170,7 +172,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const brandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -187,7 +189,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const ageCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -204,6 +206,108 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
|
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
|
||||||
|
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||||
|
// category real, solo sus hijos -- ver categoryGroups arriba). Mientras
|
||||||
|
// esa sea la categoria activa, las facetas en vivo de marca/edad no
|
||||||
|
// pueden filtrar por ella y se degrada al conteo client-side, que si
|
||||||
|
// sabe expandir el grupo via matchesCategory/allowedCategories.
|
||||||
|
const categoryIsLeafOrNone = category === "Todos" || !categoryGroups[category];
|
||||||
|
|
||||||
|
// Conteos reales via Meilisearch (ADR 0006 Etapa 2 Fase 3b): un fetch
|
||||||
|
// por dimension contra /api/search, cada uno omitiendo su propio filtro
|
||||||
|
// (mismo criterio de auto-exclusion que el fallback de arriba) para que
|
||||||
|
// los conteos reflejen el subconjunto ya filtrado por las OTRAS
|
||||||
|
// dimensiones y se puedan seguir eligiendo otros valores. `limit=0` evita
|
||||||
|
// rehidratar precios de productos que no vamos a mostrar -- solo importan
|
||||||
|
// `facetDistribution` y `count`.
|
||||||
|
const [liveFacets, setLiveFacets] = useState<{
|
||||||
|
category: Record<string, number> | null;
|
||||||
|
categoryTotal: number | null;
|
||||||
|
brand: Record<string, number> | null;
|
||||||
|
brandTotal: number | null;
|
||||||
|
ageRange: Record<string, number> | null;
|
||||||
|
ageTotal: number | null;
|
||||||
|
}>({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
function buildParams(omit: "category" | "brand" | "ageRange"): URLSearchParams {
|
||||||
|
const params = new URLSearchParams({ limit: "0" });
|
||||||
|
if (query) params.set("q", query);
|
||||||
|
if (collection) params.set("collection", collection);
|
||||||
|
if (omit !== "category" && category !== "Todos" && categoryIsLeafOrNone) {
|
||||||
|
params.set("category", category);
|
||||||
|
}
|
||||||
|
if (omit !== "brand" && brand !== "Todas") params.set("brand", brand);
|
||||||
|
if (omit !== "ageRange" && age !== "Todas") params.set("ageRange", age);
|
||||||
|
if (maxPrice > 0) params.set("maxPrice", String(maxPrice));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFacet(omit: "category" | "brand" | "ageRange") {
|
||||||
|
const response = await fetch(`/api/search?${buildParams(omit).toString()}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return (await response.json()) as { facetDistribution?: FacetDistribution };
|
||||||
|
}
|
||||||
|
|
||||||
|
// El "count"/estimatedTotalHits que devuelve /api/search viene en 0 con
|
||||||
|
// limit=0 (confirmado en vivo contra Meilisearch real -- limit>=1 lo
|
||||||
|
// reporta bien, limit=0 no). facetDistribution si es correcto con
|
||||||
|
// limit=0, y cada producto tiene siempre un valor para cada dimension
|
||||||
|
// (mapProduct usa defaults como "ARI Shopping"/"Todas", nunca null), asi
|
||||||
|
// que sumar sus valores da el total real sin depender de ese campo.
|
||||||
|
function sumFacetValues(facet: Record<string, number> | null): number {
|
||||||
|
return facet ? Object.values(facet).reduce((sum, value) => sum + value, 0) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Promise.all([fetchFacet("category"), fetchFacet("brand"), fetchFacet("ageRange")])
|
||||||
|
.then(([categoryRes, brandRes, ageRes]) => {
|
||||||
|
// Un facetDistribution vacio ({}) es la senal de degrade explicita
|
||||||
|
// de /api/search (provider sin searchWithFacets, o Meilisearch
|
||||||
|
// sin indices todavia) -- distinto de una faceta real con 0
|
||||||
|
// resultados, que igual trae la clave presente.
|
||||||
|
const categoryFacet = categoryRes.facetDistribution && "category" in categoryRes.facetDistribution ? categoryRes.facetDistribution.category : null;
|
||||||
|
const brandFacet = brandRes.facetDistribution && "brand" in brandRes.facetDistribution ? brandRes.facetDistribution.brand : null;
|
||||||
|
const ageFacet = ageRes.facetDistribution && "ageRange" in ageRes.facetDistribution ? ageRes.facetDistribution.ageRange : null;
|
||||||
|
setLiveFacets({
|
||||||
|
category: categoryFacet,
|
||||||
|
categoryTotal: categoryFacet ? sumFacetValues(categoryFacet) : null,
|
||||||
|
brand: categoryIsLeafOrNone ? brandFacet : null,
|
||||||
|
brandTotal: categoryIsLeafOrNone && brandFacet ? sumFacetValues(brandFacet) : null,
|
||||||
|
ageRange: categoryIsLeafOrNone ? ageFacet : null,
|
||||||
|
ageTotal: categoryIsLeafOrNone && ageFacet ? sumFacetValues(ageFacet) : null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as Error).name !== "AbortError") {
|
||||||
|
setLiveFacets({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||||
|
|
||||||
|
const categoryCounts = useMemo(
|
||||||
|
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||||
|
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
||||||
|
);
|
||||||
|
const brandCounts = useMemo(
|
||||||
|
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||||
|
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
||||||
|
);
|
||||||
|
const ageCounts = useMemo(
|
||||||
|
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||||
|
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
||||||
|
);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
@@ -219,18 +323,18 @@ export function CatalogClient({
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{breadcrumb.length > 0 && (
|
{breadcrumb.length > 0 && (
|
||||||
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
|
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
|
||||||
<Link href="/catalog" prefetch={false} className="hover:text-fuchsia-600">
|
<Link href="/catalog" prefetch={false} className="hover:text-ari-coral">
|
||||||
Inicio
|
Inicio
|
||||||
</Link>
|
</Link>
|
||||||
{breadcrumb.map((node, index) => (
|
{breadcrumb.map((node, index) => (
|
||||||
<span key={node.id} className="flex items-center gap-1.5">
|
<span key={node.id} className="flex items-center gap-1.5">
|
||||||
<ChevronRight className="size-3" />
|
<ChevronRight className="size-3" />
|
||||||
{index === breadcrumb.length - 1 ? (
|
{index === breadcrumb.length - 1 ? (
|
||||||
<span aria-current="page" className="text-slate-900">
|
<span aria-current="page" className="text-ari-marino">
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-fuchsia-600">
|
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-ari-coral">
|
||||||
{node.name}
|
{node.name}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -238,23 +342,27 @@ export function CatalogClient({
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
||||||
Catálogo completo
|
Catálogo completo
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-2 text-5xl font-black tracking-tight">Todos los tesoros</h1>
|
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">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-3xl border border-slate-100 bg-white p-6 shadow-lg shadow-slate-900/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-cyan-600" />
|
<SlidersHorizontal className="size-5 text-ari-coral-claro" />
|
||||||
Filtros
|
Filtros
|
||||||
</strong>
|
</strong>
|
||||||
<button onClick={reset} className="text-xs font-black text-fuchsia-600">
|
<button onClick={reset} className="text-xs font-black text-ari-coral">
|
||||||
<X className="mr-1 inline size-3" />
|
<X className="mr-1 inline size-3" />
|
||||||
Limpiar
|
Limpiar
|
||||||
</button>
|
</button>
|
||||||
@@ -288,7 +396,7 @@ export function CatalogClient({
|
|||||||
step={5000}
|
step={5000}
|
||||||
value={maxPrice}
|
value={maxPrice}
|
||||||
onChange={(event) => setMaxPrice(Number(event.target.value))}
|
onChange={(event) => setMaxPrice(Number(event.target.value))}
|
||||||
className="w-full accent-fuchsia-500"
|
className="w-full accent-ari-coral"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs font-bold text-slate-500">
|
<p className="mt-2 text-xs font-bold text-slate-500">
|
||||||
Hasta ${maxPrice.toLocaleString("es-CO")}
|
Hasta ${maxPrice.toLocaleString("es-CO")}
|
||||||
@@ -308,7 +416,7 @@ export function CatalogClient({
|
|||||||
<select
|
<select
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => setSort(event.target.value)}
|
onChange={(event) => setSort(event.target.value)}
|
||||||
className="h-11 rounded-full border border-slate-200 bg-white px-4 text-sm font-bold"
|
className="h-11 rounded-lg border border-slate-200 bg-white px-4 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<option value="featured">Destacados</option>
|
<option value="featured">Destacados</option>
|
||||||
<option value="new">Novedades</option>
|
<option value="new">Novedades</option>
|
||||||
@@ -327,10 +435,10 @@ export function CatalogClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
|
<div className="grid min-h-80 place-items-center rounded-lg border border-dashed border-slate-300 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl font-black">No encontramos productos</p>
|
<p className="text-xl font-black">No encontramos productos</p>
|
||||||
<Button variant="magenta" className="mt-4" onClick={reset}>
|
<Button variant="primary" className="mt-4" onClick={reset}>
|
||||||
Restablecer filtros
|
Restablecer filtros
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,10 +472,10 @@ function Filter({
|
|||||||
type="button"
|
type="button"
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => onChange(value)}
|
onClick={() => onChange(value)}
|
||||||
className={`rounded-full px-3 py-2 text-xs font-bold transition ${
|
className={`rounded-md px-3 py-2 text-xs font-bold transition ${
|
||||||
selected === value
|
selected === value
|
||||||
? "bg-slate-950 text-white"
|
? "bg-ari-marino text-white"
|
||||||
: "bg-slate-100 text-slate-600 hover:bg-cyan-100"
|
: "bg-slate-100 text-slate-600 hover:bg-ari-coral-claro/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
|
import { formatMoney } from "@/lib/utils";
|
||||||
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
|
||||||
|
const SELECT_CLASS =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
|
||||||
|
// 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 {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PseBank {
|
||||||
|
pseCode: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "address" | "shipping" | "payment";
|
||||||
|
|
||||||
|
async function postJson(url: string, body: unknown) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckoutClient() {
|
||||||
|
const { cartId, items, hasHydrated } = useCartStore();
|
||||||
|
const [step, setStep] = useState<Step>("address");
|
||||||
|
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 [selectedOptionId, setSelectedOptionId] = useState<string | null>(null);
|
||||||
|
const [shippingTotal, setShippingTotal] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
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("");
|
||||||
|
|
||||||
|
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
||||||
|
const currency = items[0]?.product.currency;
|
||||||
|
|
||||||
|
async function submitAddress(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
const response = await fetch(`/api/cart/shipping-options?cartId=${cartId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const options: ShippingOption[] = data.options ?? [];
|
||||||
|
setShippingOptions(options);
|
||||||
|
setSelectedOptionId(options[0]?.id ?? null);
|
||||||
|
setStep("shipping");
|
||||||
|
} catch (submitError) {
|
||||||
|
setError((submitError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitShipping() {
|
||||||
|
if (!cartId || !selectedOptionId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const cart = await postJson("/api/cart/shipping-method", { cartId, optionId: selectedOptionId });
|
||||||
|
if (cart.error) throw new Error(cart.error);
|
||||||
|
setShippingTotal(cart.shippingTotal ?? 0);
|
||||||
|
setTotal(cart.total ?? itemsSubtotal);
|
||||||
|
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) {
|
||||||
|
setError((submitError as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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}, ${selectedCiudad?.name ?? ""}${selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}\nTeléfono: ${form.phone}`;
|
||||||
|
void navigator.clipboard?.writeText(message);
|
||||||
|
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasHydrated) return null;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg px-4 py-24 text-center">
|
||||||
|
<p className="text-lg font-bold">Tu carrito está vacío.</p>
|
||||||
|
<Button asChild variant="primary" className="mt-6"><Link href="/catalog">Ver catálogo</Link></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid max-w-5xl gap-10 px-4 py-10 lg:grid-cols-[1fr_.8fr] lg:py-16">
|
||||||
|
<div>
|
||||||
|
<Link href="/" className="mb-6 inline-flex items-center gap-1.5 text-sm font-bold text-slate-500 hover:text-ari-marino"><ArrowLeft className="size-4" />Seguir comprando</Link>
|
||||||
|
<h1 className="text-3xl font-black tracking-tight sm:text-4xl">Confirmar pedido</h1>
|
||||||
|
|
||||||
|
{step === "address" && (
|
||||||
|
<form onSubmit={submitAddress} className="mt-8 space-y-4">
|
||||||
|
<div className="grid gap-4 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 placeholder="Dirección" required value={form.address1} onChange={(event) => setForm({ ...form, address1: event.target.value })} />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<select
|
||||||
|
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 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 type="email" placeholder="Correo" required value={form.email} onChange={(event) => setForm({ ...form, email: 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}>{loading ? "Guardando…" : "Continuar a envío"}</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "shipping" && (
|
||||||
|
<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}, {selectedCiudad?.name}{selectedDepartamento ? `, ${selectedDepartamento.name}` : ""}</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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"}`}>
|
||||||
|
<span className="flex items-center gap-3 font-bold"><Truck className="size-5 text-ari-marino" />{option.name}</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="font-black">{formatMoney(option.amount, currency)}</span>
|
||||||
|
<input type="radio" name="shipping" checked={selectedOptionId === option.id} onChange={() => setSelectedOptionId(option.id)} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{shippingOptions.length === 0 && <p className="text-sm text-slate-500">No hay opciones de envío disponibles para esta dirección.</p>}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm font-bold text-rose-600">{error}</p>}
|
||||||
|
<Button variant="primary" size="lg" className="w-full" disabled={loading || !selectedOptionId} onClick={submitShipping}>{loading ? "Guardando…" : "Continuar"}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "payment" && (
|
||||||
|
<form onSubmit={submitPayment} className="mt-8 space-y-4">
|
||||||
|
<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="mt-1 font-bold">{form.firstName} {form.lastName}</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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 className="h-fit rounded-3xl border border-slate-200 p-6">
|
||||||
|
<h2 className="font-black">Tu pedido</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.lineItemId} className="flex items-center gap-3">
|
||||||
|
<div className="relative size-14 shrink-0 overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.thumbnail} alt={item.product.name} fill sizes="56px" className="object-cover" /></div>
|
||||||
|
<div className="flex-1"><p className="text-sm font-bold leading-5">{item.product.name}</p><p className="text-xs text-slate-500">Cantidad: {item.quantity}</p></div>
|
||||||
|
<p className="text-sm font-black">{formatMoney((item.product.price ?? 0) * item.quantity, item.product.currency)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{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 === "payment" ? total : itemsSubtotal, currency)}</span></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,16 +10,20 @@ type TileContent = { subtitle: string; image?: string; color: string };
|
|||||||
// (ver ADR 0005) -- solo se mapean imagen/subtitulo para las que ya tienen
|
// (ver ADR 0005) -- solo se mapean imagen/subtitulo para las que ya tienen
|
||||||
// un asset en public/images/categories/. Las demas caen al tile de solo
|
// un asset en public/images/categories/. Las demas caen al tile de solo
|
||||||
// gradiente en vez de mostrar una imagen que no les corresponde.
|
// gradiente en vez de mostrar una imagen que no les corresponde.
|
||||||
|
// Fondos pastel solidos (patron Walmart-inspirado, ADR 0008 sub-fase 4)
|
||||||
|
// en vez de los gradientes saturados originales del esquema arcoiris --
|
||||||
|
// un tono pastel distinto por categoria, no una escala de marca (serian
|
||||||
|
// todos casi iguales), pero coordinados en la misma temperatura suave.
|
||||||
const tileContent: Record<string, TileContent> = {
|
const tileContent: Record<string, TileContent> = {
|
||||||
juguetes: { subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", color: "from-yellow-300 to-orange-400" },
|
juguetes: { subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", color: "bg-amber-100" },
|
||||||
"mochilas-y-morrales": { subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", color: "from-cyan-400 to-blue-500" },
|
"mochilas-y-morrales": { subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", color: "bg-sky-100" },
|
||||||
"bebes-y-primera-infancia": { subtitle: "Primeros descubrimientos", color: "from-pink-300 to-fuchsia-400" },
|
"bebes-y-primera-infancia": { subtitle: "Primeros descubrimientos", color: "bg-rose-100" },
|
||||||
educativos: { subtitle: "Aprender jugando", color: "from-emerald-400 to-teal-600" },
|
educativos: { subtitle: "Aprender jugando", color: "bg-emerald-100" },
|
||||||
"accion-y-aventura": { subtitle: "Para explorar sin límites", color: "from-red-500 to-rose-700" },
|
"accion-y-aventura": { subtitle: "Para explorar sin límites", color: "bg-orange-100" },
|
||||||
variedades: { subtitle: "Un poco de todo", color: "from-violet-400 to-purple-600" },
|
variedades: { subtitle: "Un poco de todo", color: "bg-violet-100" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
|
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "bg-slate-100" };
|
||||||
|
|
||||||
// Tile individual reutilizado tanto por la seccion de mundos del home
|
// Tile individual reutilizado tanto por la seccion de mundos del home
|
||||||
// como por el grid del catalogo (tiles promocionales intercalados,
|
// como por el grid del catalogo (tiles promocionales intercalados,
|
||||||
@@ -31,20 +35,24 @@ export function PromoTile({ category, className = "" }: { category: Category; cl
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/catalog?category=${encodeURIComponent(category.name)}`}
|
href={`/catalog?category=${encodeURIComponent(category.name)}`}
|
||||||
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`, className)}
|
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] ${content.color} shadow-sm ring-1 ring-slate-900/5`, className)}
|
||||||
>
|
>
|
||||||
|
{/* Patron Walmart: foto recortada (object-cover) llenando casi toda
|
||||||
|
la tarjeta, con solo un margen delgado del pastel como marco --
|
||||||
|
no flotando chica con espacio muerto alrededor (object-contain,
|
||||||
|
intento anterior, reportado como "no aprovecha el espacio"), ni
|
||||||
|
a pantalla completa sin marco (el intento antes de ese). */}
|
||||||
{content.image && (
|
{content.image && (
|
||||||
<>
|
<div className="absolute inset-3 bottom-24 overflow-hidden rounded-2xl">
|
||||||
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
|
<Image src={content.image} alt="" fill sizes="(max-width: 640px) 45vw, (max-width: 1024px) 25vw, 14vw" className="object-cover transition duration-500 group-hover:scale-105" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
|
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-slate-900">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-black">{category.name}</h3>
|
<h3 className="text-xl font-black">{category.name}</h3>
|
||||||
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
|
<p className="mt-1 text-sm font-semibold text-slate-600">{content.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
|
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-ari-marino text-white transition group-hover:rotate-45">
|
||||||
<ArrowUpRight />
|
<ArrowUpRight />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,10 +69,10 @@ export function CategoryTiles({ categories }: { categories: Category[] }) {
|
|||||||
<section className="mx-auto max-w-7xl px-4 py-14">
|
<section className="mx-auto max-w-7xl px-4 py-14">
|
||||||
<div className="mb-8 flex items-end justify-between gap-4">
|
<div className="mb-8 flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Explora por mundos</span>
|
||||||
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
|
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
|
<Link href="/catalog" className="hidden font-black text-ari-marino sm:block">Ver todo →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{roots.map((category) => (
|
{roots.map((category) => (
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export function HeroBento() {
|
|
||||||
const reduceMotion = useReducedMotion();
|
|
||||||
const initial = reduceMotion ? false : { opacity: 0, y: 20 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10">
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.55fr_.8fr]">
|
|
||||||
<motion.article initial={initial} animate={{ opacity: 1, y: 0 }} className="grid min-h-[470px] overflow-hidden rounded-[2rem] bg-fuchsia-500 text-white shadow-2xl lg:min-h-[560px] lg:grid-cols-[.82fr_1.18fr]">
|
|
||||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-fuchsia-600 via-pink-500 to-rose-500 p-7 lg:p-11">
|
|
||||||
<div className="mb-5 inline-flex w-fit items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div>
|
|
||||||
<h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-7xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1>
|
|
||||||
<p className="mt-6 max-w-md text-base font-semibold text-white/95 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p>
|
|
||||||
<Button asChild variant="yellow" size="lg" className="mt-8 w-fit"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button>
|
|
||||||
</div>
|
|
||||||
<div className="relative min-h-[360px] bg-white lg:min-h-full">
|
|
||||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 42vw" className="object-cover object-center" />
|
|
||||||
</div>
|
|
||||||
</motion.article>
|
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
|
||||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .08 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-red-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
|
||||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-red-600 to-rose-700 p-6"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5 w-fit"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div>
|
|
||||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
|
||||||
</motion.article>
|
|
||||||
|
|
||||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .14 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-violet-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
|
||||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-violet-600 to-cyan-600 p-6"><div className="inline-flex w-fit items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 w-fit border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div>
|
|
||||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
|
||||||
</motion.article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// ADR 0008 sub-fase 4: hero azul con dots de paginacion (carrusel), en vez
|
||||||
|
// del bento estatico de 3 tarjetas en fucsia/rojo/violeta del esquema
|
||||||
|
// arcoiris original. Un slide a la vez, fondo azul de marca en los tres,
|
||||||
|
// para leerse como una sola pieza de marca en vez de 3 bloques de colores
|
||||||
|
// distintos.
|
||||||
|
//
|
||||||
|
// ADR 0010: los slides ya no son un array hardcodeado -- se derivan de
|
||||||
|
// productos activos reales del catalogo (pasados por prop desde
|
||||||
|
// app/page.tsx, que ya trae `products` del backend). Fallback estatico
|
||||||
|
// solo para el caso borde de una tienda sin ningun producto activo.
|
||||||
|
type Slide = {
|
||||||
|
id: string;
|
||||||
|
badge: string;
|
||||||
|
icon: typeof Sparkles;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
image: string;
|
||||||
|
imageAlt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_SLIDES: Slide[] = [
|
||||||
|
{
|
||||||
|
id: "celebracion",
|
||||||
|
badge: "Celebración ARI",
|
||||||
|
icon: Sparkles,
|
||||||
|
title: "Hasta 50% OFF",
|
||||||
|
subtitle: "en tesoros seleccionados",
|
||||||
|
description: "Personajes, mochilas y regalos que convierten cualquier día en una celebración.",
|
||||||
|
href: "/catalog",
|
||||||
|
image: "/images/promo/hero-vibrant.webp",
|
||||||
|
imageAlt: "Promoción principal de Ari Shopping",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "marvel",
|
||||||
|
badge: "Héroes favoritos",
|
||||||
|
icon: BadgePercent,
|
||||||
|
title: "Acción y aventura",
|
||||||
|
subtitle: "colección Marvel",
|
||||||
|
description: "Figuras, mochilas y sets listos para la próxima misión.",
|
||||||
|
href: "/catalog?collection=Marvel",
|
||||||
|
image: "/images/promo/promo-marvel.webp",
|
||||||
|
imageAlt: "Colección Marvel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regalos",
|
||||||
|
badge: "Novedad",
|
||||||
|
icon: Sparkles,
|
||||||
|
title: "Tesoros para regalar",
|
||||||
|
subtitle: "",
|
||||||
|
description: "Personajes y peluches para sorprender en cualquier ocasión.",
|
||||||
|
href: "/catalog",
|
||||||
|
image: "/images/promo/promo-characters.webp",
|
||||||
|
imageAlt: "Personajes y peluches",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_SLIDES = 5;
|
||||||
|
const AUTOPLAY_MS = 6000;
|
||||||
|
|
||||||
|
function slidesFromProducts(products: Product[]): Slide[] {
|
||||||
|
return products.slice(0, MAX_SLIDES).map((product) => {
|
||||||
|
const hasDiscount = product.compareAtPrice !== null && product.compareAtPrice > (product.price ?? 0);
|
||||||
|
const pctOff = hasDiscount && product.price !== null
|
||||||
|
? Math.round(100 - (product.price / (product.compareAtPrice as number)) * 100)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
badge: hasDiscount ? `${pctOff}% OFF` : product.collection ? `Colección ${product.collection}` : "Destacado",
|
||||||
|
icon: hasDiscount ? BadgePercent : Sparkles,
|
||||||
|
title: product.name,
|
||||||
|
subtitle: product.priceLabel,
|
||||||
|
description: product.shortDescription || "Descúbrelo en la tienda ARI Shopping.",
|
||||||
|
href: `/product/${product.slug}`,
|
||||||
|
image: product.image,
|
||||||
|
imageAlt: product.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroCarousel({ products }: { products: Product[] }) {
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const slides = useMemo(() => {
|
||||||
|
const fromProducts = slidesFromProducts(products);
|
||||||
|
return fromProducts.length > 0 ? fromProducts : FALLBACK_SLIDES;
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduceMotion) return;
|
||||||
|
const timer = setInterval(() => setIndex((current) => (current + 1) % slides.length), AUTOPLAY_MS);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [reduceMotion, slides.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (index >= slides.length) setIndex(0);
|
||||||
|
}, [slides.length, index]);
|
||||||
|
|
||||||
|
const slide = slides[index];
|
||||||
|
if (!slide) return null;
|
||||||
|
const Icon = slide.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10">
|
||||||
|
<div className="relative overflow-hidden rounded-[2rem] bg-ari-marino text-white shadow-2xl">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.article
|
||||||
|
key={slide.id}
|
||||||
|
initial={reduceMotion ? false : { opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={reduceMotion ? undefined : { opacity: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid min-h-[420px] lg:min-h-[500px] lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex flex-col justify-center p-7 lg:p-14">
|
||||||
|
<div className="mb-5 inline-flex w-fit items-center gap-2 rounded-full bg-ari-coral-claro px-4 py-2 text-xs font-black uppercase tracking-wider text-ari-marino">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{slide.badge}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl font-black leading-[.95] tracking-[-.04em] sm:text-6xl">{slide.title}</h1>
|
||||||
|
{slide.subtitle && <p className="mt-2 text-2xl font-bold text-ari-coral-claro">{slide.subtitle}</p>}
|
||||||
|
<p className="mt-6 max-w-md text-base font-semibold text-white/90 sm:text-lg">{slide.description}</p>
|
||||||
|
<Button asChild variant="yellow" size="lg" className="mt-8 w-fit">
|
||||||
|
<Link href={slide.href}>
|
||||||
|
Comprar ahora <ArrowRight className="size-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-[280px] bg-white lg:min-h-full">
|
||||||
|
<Image src={slide.image} alt={slide.imageAlt} fill priority={index === 0} sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover object-center" />
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-5 z-20 flex justify-center gap-2">
|
||||||
|
{slides.map((item, itemIndex) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Ir a la diapositiva ${itemIndex + 1}: ${item.badge}`}
|
||||||
|
aria-current={itemIndex === index ? "true" : undefined}
|
||||||
|
onClick={() => setIndex(itemIndex)}
|
||||||
|
className={`h-2.5 rounded-full transition-all ${itemIndex === index ? "w-7 bg-ari-coral-claro" : "w-2.5 bg-white/50 hover:bg-white/80"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,42 @@
|
|||||||
import Image from "next/image";
|
|
||||||
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-slate-950 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 items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></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-yellow-300 px-4 text-sm font-black text-slate-950">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} /><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 {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-yellow-300">{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>;
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
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, Menu, ShoppingCart, UserRound } from "lucide-react";
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
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 { 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[] };
|
||||||
@@ -35,6 +35,9 @@ 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):
|
||||||
@@ -46,27 +49,53 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
||||||
<div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
|
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm">
|
||||||
Paga tus compras a cuotas · Envíos nacionales · <span className="underline 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-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500" />
|
<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-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[92px_minmax(420px,1fr)_auto] lg:gap-8">
|
{/* ADR 0010: en mobile/tablet el buscador se movio a una segunda
|
||||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
|
fila a todo el ancho, debajo de logo+iconos (antes competia por
|
||||||
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
espacio en una sola fila con el grid_[auto_1fr_auto] y quedaba
|
||||||
</Link>
|
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
||||||
<PredictiveSearch products={products} />
|
original -- ahi sobra espacio y partirla en dos solo alargaria
|
||||||
<div className="flex items-center gap-2">
|
el header sin necesidad. */}
|
||||||
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
|
||||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
|
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
|
||||||
<Heart />
|
{/* Isotipo circular con icono + wordmark integrados (como en la
|
||||||
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-violet-600 text-[10px] font-black text-white">{wishlistCount}</span>}
|
version previa a ADR 0008), pedido explicito del usuario en
|
||||||
</Button>
|
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
||||||
<Button variant="magenta" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative shadow-lg shadow-fuchsia-500/20">
|
mantiene para el footer. */}
|
||||||
<ShoppingCart />
|
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
||||||
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-yellow-300 text-[11px] font-black text-slate-950">{count}</span>
|
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
||||||
</Button>
|
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} />
|
||||||
|
</Link>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<PredictiveSearch />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Antes escondido en mobile (solo alcanzable desde el menu
|
||||||
|
hamburguesa) -- pedido explicito del usuario: visible
|
||||||
|
siempre en el header, con un punto verde cuando hay sesion
|
||||||
|
iniciada (mismo patron que el badge de cantidad del
|
||||||
|
carrito/favoritos, pero de presencia, no de conteo). */}
|
||||||
|
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
|
||||||
|
<UserRound />
|
||||||
|
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
||||||
|
<Heart />
|
||||||
|
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
|
||||||
|
<ShoppingCart />
|
||||||
|
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 lg:hidden">
|
||||||
|
<PredictiveSearch />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +111,7 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<SheetDescription>Categorías y colecciones del catálogo.</SheetDescription>
|
<SheetDescription>Categorías y colecciones del catálogo.</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p>
|
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-ari-coral">Categorías</p>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
|
<Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
|
||||||
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
||||||
@@ -90,9 +119,9 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
</div>
|
</div>
|
||||||
{collections.length > 0 && (
|
{collections.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">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">
|
||||||
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
|
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">{collection}</Link>)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -104,19 +133,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-violet-600">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="magenta" 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,16 +153,16 @@ function useCategoryNavigationState() {
|
|||||||
function pillClass(active: boolean): string {
|
function pillClass(active: boolean): string {
|
||||||
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
|
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
|
||||||
active
|
active
|
||||||
? "bg-gradient-to-r from-cyan-500 to-violet-500 text-white shadow-lg shadow-violet-950/30"
|
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
||||||
: "text-white/90 hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white"
|
: "text-white/90 hover:bg-white/10 hover:text-white"
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mobileLinkClass(active: boolean): string {
|
function mobileLinkClass(active: boolean): string {
|
||||||
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
|
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
|
||||||
active
|
active
|
||||||
? "border-violet-500 bg-violet-50 text-violet-700"
|
? "border-ari-coral bg-ari-coral/5 text-ari-coral"
|
||||||
: "border-slate-200 hover:border-cyan-400 hover:bg-cyan-50"
|
: "border-slate-200 hover:border-ari-coral/50 hover:bg-ari-coral/5"
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +187,8 @@ function CategoryNavBar({
|
|||||||
isAllActive: boolean;
|
isAllActive: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
|
<nav className="bg-ari-marino text-white">
|
||||||
<div className="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">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -195,6 +212,24 @@ function CategoryNavBar({
|
|||||||
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -212,8 +247,15 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => () => cancelClose(), []);
|
||||||
|
|
||||||
|
const openNow = () => {
|
||||||
|
cancelClose();
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
|
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
@@ -227,7 +269,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 w-[min(92vw,64rem)] rounded-2xl border border-slate-800 bg-slate-950 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}>
|
||||||
@@ -237,7 +279,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
aria-current={isActive(root.name) ? "page" : undefined}
|
aria-current={isActive(root.name) ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className={`block text-sm font-black uppercase tracking-wide transition ${
|
className={`block text-sm font-black uppercase tracking-wide transition ${
|
||||||
isActive(root.name) ? "text-fuchsia-400" : "text-white hover:text-fuchsia-400"
|
isActive(root.name) ? "text-ari-coral-claro" : "text-white hover:text-ari-coral-claro"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{root.name}
|
{root.name}
|
||||||
@@ -251,7 +293,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
prefetch={false}
|
prefetch={false}
|
||||||
aria-current={isActive(child.name) ? "page" : undefined}
|
aria-current={isActive(child.name) ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-violet-300" : "text-white/70 hover:text-white"}`}
|
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-ari-coral-claro" : "text-white/70 hover:text-white"}`}
|
||||||
>
|
>
|
||||||
{child.name}
|
{child.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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="magenta" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
|
|
||||||
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 &&
|
||||||
@@ -33,15 +35,16 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/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-slate-50">
|
<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>
|
<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>
|
||||||
<div className="absolute left-3 top-3 flex gap-2">{discountPercent && discountPercent > 0 && <Badge className="bg-rose-600 text-white">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><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>
|
||||||
<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-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
{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>
|
||||||
</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-full bg-cyan-50 px-2 py-1 text-cyan-700">{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-slate-950 group-hover:text-fuchsia-600">{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>
|
||||||
<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">
|
||||||
@@ -53,9 +56,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
|||||||
aria-pressed={selectedVariantId === variant.id}
|
aria-pressed={selectedVariantId === variant.id}
|
||||||
aria-label={`${variant.name}: ${variant.value}`}
|
aria-label={`${variant.name}: ${variant.value}`}
|
||||||
onClick={() => setSelectedVariantId(variant.id)}
|
onClick={() => setSelectedVariantId(variant.id)}
|
||||||
className={`rounded-full border px-2.5 py-1 text-xs font-bold transition ${
|
className={`rounded-md border px-2.5 py-1 text-xs font-bold transition ${
|
||||||
selectedVariantId === variant.id
|
selectedVariantId === variant.id
|
||||||
? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700"
|
? "border-ari-coral bg-ari-coral/10 text-ari-coral"
|
||||||
: "border-slate-200 text-slate-600 hover:border-slate-400"
|
: "border-slate-200 text-slate-600 hover:border-slate-400"
|
||||||
} ${!variant.available ? "cursor-not-allowed opacity-40" : ""}`}
|
} ${!variant.available ? "cursor-not-allowed opacity-40" : ""}`}
|
||||||
>
|
>
|
||||||
@@ -67,12 +70,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="text-sm font-black">{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="magenta" 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,22 +109,90 @@ 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>
|
||||||
<Dialog open={open} onOpenChange={changeOpen}>
|
<div className="relative order-1 lg:order-2">
|
||||||
<DialogTrigger asChild>
|
<Dialog open={open} onOpenChange={changeOpen}>
|
||||||
<button type="button" className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
|
<DialogTrigger asChild>
|
||||||
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
|
<button
|
||||||
<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>
|
type="button"
|
||||||
</button>
|
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
|
||||||
</DialogTrigger>
|
onMouseMove={trackHoverZoom}
|
||||||
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
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,
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
||||||
<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>
|
||||||
@@ -65,8 +204,27 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
|||||||
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
|
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</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,
|
||||||
|
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-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "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><Button variant="magenta" size="lg" className="w-full" 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,44 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
export function PredictiveSearch() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalized = query.trim().toLocaleLowerCase("es");
|
const [matches, setMatches] = useState<Product[]>([]);
|
||||||
const matches = useMemo(
|
const [loading, setLoading] = useState(false);
|
||||||
() => normalized.length < 2
|
const normalized = query.trim();
|
||||||
? []
|
|
||||||
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
|
useEffect(() => {
|
||||||
[normalized, products],
|
if (normalized.length < 2) {
|
||||||
);
|
setMatches([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetch(`/api/search?q=${encodeURIComponent(normalized)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data: { products?: Product[] }) => {
|
||||||
|
setMatches((data.products ?? []).slice(0, 6));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as Error).name !== "AbortError") setMatches([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [normalized]);
|
||||||
|
|
||||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -29,22 +53,61 @@ export function PredictiveSearch({ products }: { products: Product[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="relative w-full">
|
<form onSubmit={submit} className="relative w-full">
|
||||||
<div className="rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 p-[2px] shadow-lg shadow-violet-500/10">
|
<div className="flex items-center rounded-full border-2 border-slate-200 bg-white pl-5 pr-1.5 shadow-sm transition focus-within:border-ari-marino focus-within:ring-4 focus-within:ring-ari-coral-claro/30">
|
||||||
<div className="relative rounded-full bg-white">
|
{/* input propio (no el <Input> compartido) -- ese componente trae su
|
||||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-violet-600" />
|
propio focus:ring-4 focus:ring-cyan-300/30 hardcodeado que no se
|
||||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-0 bg-transparent pl-[52px] pr-12 shadow-none focus-visible:ring-violet-300/30" />
|
anulaba de forma confiable via tailwind-merge dentro de este
|
||||||
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
contenedor compuesto (input + boton circular), causando un
|
||||||
</div>
|
segundo ring visible al hacer foco. Mas simple y robusto no
|
||||||
|
heredar clases de foco que hay que pelear despues.
|
||||||
|
|
||||||
|
focus-visible:outline-none (no outline-none a secas): globals.css
|
||||||
|
define un outline de accesibilidad global por teclado
|
||||||
|
(input:focus-visible, azul de marca) que le gana en
|
||||||
|
especificidad a un outline-none plano. El wrapper de este
|
||||||
|
componente ya tiene su propio indicador de foco visible
|
||||||
|
(focus-within:border-ari-marino + ring amarillo, mismo patron
|
||||||
|
que button.tsx) que cumple el mismo proposito de accesibilidad
|
||||||
|
sin duplicarlo -- variante focus-visible: en vez de la base
|
||||||
|
iguala la especificidad de la regla global (mismo truco que ya
|
||||||
|
usa button.tsx) y evita el doble indicador de foco. */}
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Busca juguetes, mochilas, personajes…"
|
||||||
|
className="h-[48px] w-full border-0 bg-transparent p-0 text-sm text-slate-950 focus-visible:outline-none"
|
||||||
|
/>
|
||||||
|
{query && <button type="button" aria-label="Limpiar búsqueda" className="mr-1 shrink-0 rounded-full p-1.5 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||||
|
<button type="submit" aria-label="Buscar" className="grid size-10 shrink-0 place-items-center rounded-full bg-ari-marino text-white transition hover:brightness-110">
|
||||||
|
<Search className="size-4.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{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-4 text-center text-sm text-slate-500 shadow-2xl">
|
||||||
|
Buscando…
|
||||||
|
</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) => (
|
||||||
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-violet-50" onClick={() => setQuery("")}>
|
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-ari-marino/5" onClick={() => setQuery("")}>
|
||||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||||
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<button type="submit" className="mt-1 w-full rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white hover:bg-violet-700">Buscar “{query.trim()}” en todo el catálogo</button>
|
<button type="submit" className="mt-1 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-extrabold transition-all focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan-300/40 disabled:pointer-events-none disabled:opacity-50",
|
// ADR 0007 (Opcion 2, "Atelier calido"): radio moderado en vez de
|
||||||
|
// rounded-full, ring de foco tintado con el acento de marca en vez de
|
||||||
|
// cyan generico.
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-extrabold transition-all focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ari-cobre/40 disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-slate-950 text-white shadow-lg hover:-translate-y-0.5 hover:bg-slate-800",
|
default: "bg-slate-950 text-white shadow-lg hover:-translate-y-0.5 hover:bg-slate-800",
|
||||||
magenta: "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-lg shadow-pink-500/25 hover:-translate-y-0.5",
|
// Unico color reservado a accion (nunca decorativo) -- bloque
|
||||||
|
// solido, no gradiente, sobre toda la superficie del sitio que
|
||||||
|
// ya usaba esta variant (carrito, WhatsApp, checkout, "Agregar").
|
||||||
|
primary: "bg-ari-ciruela text-white shadow-lg shadow-ari-ciruela/25 hover:-translate-y-0.5 hover:brightness-110",
|
||||||
yellow: "bg-yellow-300 text-slate-950 shadow-lg shadow-yellow-400/25 hover:-translate-y-0.5 hover:bg-yellow-200",
|
yellow: "bg-yellow-300 text-slate-950 shadow-lg shadow-yellow-400/25 hover:-translate-y-0.5 hover:bg-yellow-200",
|
||||||
outline: "border border-slate-200 bg-white text-slate-950 hover:border-cyan-400 hover:bg-cyan-50",
|
outline: "border border-slate-200 bg-white text-slate-950 hover:border-ari-cobre hover:bg-ari-cobre/10",
|
||||||
ghost: "text-slate-700 hover:bg-slate-100",
|
ghost: "text-slate-700 hover:bg-slate-100",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function WishlistDrawer() {
|
|||||||
<div>
|
<div>
|
||||||
<Link href={`/product/${product.slug}`} onClick={closeWishlist} prefetch={false} className="text-sm font-black leading-5 hover:text-fuchsia-600">{product.name}</Link>
|
<Link href={`/product/${product.slug}`} onClick={closeWishlist} prefetch={false} className="text-sm font-black leading-5 hover:text-fuchsia-600">{product.name}</Link>
|
||||||
<p className="mt-1 text-xs text-slate-500">{product.category} · {product.collection}</p>
|
<p className="mt-1 text-xs text-slate-500">{product.category} · {product.collection}</p>
|
||||||
<Button size="sm" variant="magenta" className="mt-3" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-3.5" />Agregar</Button>
|
<Button size="sm" variant="primary" className="mt-3" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-3.5" />Agregar</Button>
|
||||||
</div>
|
</div>
|
||||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeProduct(product.id)} aria-label="Quitar favorito"><Trash2 className="size-4" /></button>
|
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeProduct(product.id)} aria-label="Quitar favorito"><Trash2 className="size-4" /></button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.263
|
||||||
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.263
|
||||||
- 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 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user