Compare commits

...
44 Commits
Author SHA1 Message Date
gitea-actions 55fbf3524b chore(gitops): deploy Medusa v1.0.295 [skip ci] 2026-09-28 01:29:53 +00:00
devops 17dc9a5133 Merge branch 'ci/trivy-backend-timeout'
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 17m6s
2026-09-28 01:12:17 +00:00
devopsandClaude Opus 5.5 ba96a50a42 ci(medusa): timeout de Trivy CRITICAL 15m -> 30m (análisis de capas ~14 min)
Medido en vivo (run 296): en la imagen del backend el análisis de capas
toma ~14 min en el runner del NAS (descarga de DB 8 s, detección 9 s); con
15m hizo timeout una vez (run 294). No era el secret-scan. Solo este paso:
el HIGH reusa el análisis cacheado (~2 s) y frontend/docs tardan ~2 min.
No cambia qué se revisa ni que CRITICAL bloquea.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-28 01:12:16 +00:00
gitea-actions 91335ce023 chore(gitops): deploy Docs Portal v1.0.294 [skip ci] 2026-09-28 00:42:39 +00:00
devops 43c47d99fb chore: promote v1.0.293 to production [skip ci] 2026-09-28 00:34:16 +00:00
devops 4baabf36ce chore: release v1.0.293 [skip ci] 2026-09-28 00:34:14 +00:00
gitea-actions 3cc0a7c861 chore(gitops): deploy Medusa v1.0.292 [skip ci] 2026-09-28 00:20:14 +00:00
devops a3e9580348 Merge branch 'ci/trivy-image-scanners-vuln'
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 43s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 33s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 40m7s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m58s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 8m35s
2026-09-27 23:38:15 +00:00
devopsandClaude Opus 5.5 aaf6e14742 ci: Trivy image solo con --scanners vuln (evita timeouts de 15m)
El escaneo de secretos que Trivy hace por defecto sobre toda la imagen
(node_modules incluido) hizo timeout (context deadline exceeded, 15m) en
el gate CRITICAL del backend el 2026-09-27; el propio log de Trivy
recomienda --scanners vuln. Aplicado a los 6 pasos trivy image (CRITICAL
bloqueante y HIGH informativo — un FATAL por timeout tumba el job aunque
--exit-code sea 0) de build.yaml, build-medusa.yaml y deploy-docs.yaml.

Sin pérdida real de cobertura (auditado por devsecops-auditor): Gitleaks
ya escanea todo el árbol de cada workload antes del build (needs:
gitleaks), ningún build recibe --build-arg/--secret con credenciales, los
ENV build-only del backend no llegan al stage final, y 11 corridas
recientes de image secret-scan tuvieron 0 hallazgos. Hueco residual
aceptado: secretos en capas de imágenes base oficiales.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 23:38:14 +00:00
gitea-actions c23803656e chore(gitops): deploy Medusa v1.0.290 [skip ci] 2026-09-27 21:59:56 +00:00
devops e86a0ebc96 chore: promote v1.0.291 to production [skip ci] 2026-09-27 15:13:40 +00:00
devops b02a5e8b5f chore: release v1.0.291 [skip ci] 2026-09-27 15:13:38 +00:00
devops ca10ef6e9a Merge branch 'fix/categorias-sin-tildes-match'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 27s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 15m24s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m58s
2026-09-27 14:29:53 +00:00
devopsandClaude Opus 5.5 f6918b9eab fix: categorías con tildes sin duplicados en el importador ni links rotos
Preparación para renombrar las categorías reales con tildes/eñes
("Figuras de acción", "Muñecas y peluches", etc.):
- Importador CSV (admin): categorías y colecciones se matchean ignorando
  tildes/eñes, para que un CSV escrito sin tildes reuse la categoría
  existente en vez de crear un duplicado. Títulos de producto sin cambios.
- Catálogo: ?category= se resuelve contra los nombres reales ignorando
  tildes/mayúsculas, así los links viejos siguen funcionando.
- Plantilla CSV de ejemplo con "Figuras de acción".

Aprobado por devsecops-auditor (sin colisiones con los nombres actuales,
sin riesgo de inyección/XSS). CI guard local OK, tsc backend OK, build de
producción del storefront OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 14:29:52 +00:00
devops 8e4f3515fe chore: promote v1.0.289 to production [skip ci] 2026-09-27 13:58:23 +00:00
devops e7d7fcdb00 chore: release v1.0.289 [skip ci] 2026-09-27 13:58:21 +00:00
devops 8f9df266d3 Merge branch 'fix/ci-guard-catalog-key'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 33s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 16m18s
2026-09-27 13:41:40 +00:00
devopsandClaude Opus 5.5 ac7f55eab3 fix(ci): chequeo de remontaje del catálogo acepta la key con taxonomía
El paso "Validar navegación real del catálogo" buscaba la key exacta
`${category}:${collection}:${query}` en app/catalog/page.tsx; desde
franquicias/personajes la key también incluye franquicia/saga/personaje y
el pipeline del frontend fallaba (2 veces) aunque el comportamiento
protegido se cumple. Ahora valida por prefijo + exige los parámetros de
taxonomía. No toca ningún gate de seguridad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 13:41:39 +00:00
gitea-actions f3f6fb7166 chore(gitops): deploy Medusa v1.0.287 [skip ci] 2026-09-27 13:04:43 +00:00
devops 619228168d Merge branch 'feat/resenas-productos'
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 1m21s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 38m24s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 49s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 33s
2026-09-27 12:23:49 +00:00
devops 7f0c1571d9 Merge branch 'feat/franquicias-personajes' 2026-09-27 12:23:49 +00:00
devopsandClaude Opus 5.5 cfd9a7ec96 feat(reseñas): reseñas de productos de compradores verificados, moderadas
Backend (commerce-backend):
- Módulo product-review (tabla nueva product_review; migración generada con
  medusa db:generate, no toca tablas existentes).
- GET /store/reviews?product_id (solo aprobadas), /store/reviews/summary,
  GET /store/reviews/me y POST /store/reviews con auth de cliente: exige que
  el pedido sea del cliente, no esté cancelado y contenga el producto
  ("Compra verificada"); 1 reseña por cliente y producto; nace "pending".
- /admin/reviews (+ /:id POST status, DELETE) y página "Reseñas" en el admin
  para aprobar/rechazar/borrar.

Storefront (ecommerce):
- /cuenta/pedidos ("Mis pedidos"): historial real + diálogo "Califica tu
  compra" (radiogroup accesible, flechas de teclado).
- Ficha: sección de reseñas con "Compra verificada" y estrellas junto al
  título; tarjetas: promedio y cantidad (una llamada /api/reviews/summary).
- Accesos: "Mis pedidos y reseñas" en la cuenta y en Ayuda.

Validado en entorno descartable (Postgres/Redis/Meilisearch/Medusa propios):
E2E de API 20/20 (incluye pedido interno por draft order, cancelación y
borrado), E2E de tienda con navegador 15/15, admin "Reseñas" verificado en
build de producción. Revisado por frontend-critic (contraste de estrellas
amber-600, patrón radiogroup).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 07:57:20 +00:00
devopsandClaude Opus 5.5 472c12ec0a feat(storefront): franquicias, sagas y personajes (Disney > Toy Story > Rex)
Taxonomía desde tags de Medusa con prefijo (franquicia:/saga:/personaje:),
muchos-a-muchos (ej. "Minnie y Mickey" tiene dos personajes). Pixar va
dentro de Disney.
- lib/taxonomy.ts: helpers + árbol franquicia > saga > personaje.
- Navbar: "Personajes" pasa a mega panel por franquicia (con totales,
  sagas y personajes); el menú lateral mobile muestra franquicias y sagas.
  Fallback a colecciones si el provider no trae tags.
- Catálogo: filtros "Franquicia" y "Personaje" (acotado a la franquicia),
  chip removible de "Saga", ?franquicia/?saga/?personaje en la URL, título
  según la taxonomía de la URL, búsqueda normalizada ("spiderman" encuentra
  "Spider-Man"). Conteos client-side (no son facetas de Meilisearch).
- allowedCategories memoizado (elimina warnings exhaustive-deps).

Revisado por frontend-critic (contraste de personajes /80, H1 solo desde la
URL, Escape devuelve el foco al trigger) + Playwright: check-taxonomy
17/17 + extras, shot-navbar sin regresiones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 07:06:32 +00:00
devops 271ea30f44 chore: promote v1.0.286 to production [skip ci] 2026-09-27 05:38:30 +00:00
devops d1fa58bf24 chore: release v1.0.286 [skip ci] 2026-09-27 05:38:29 +00:00
devops 8145e90441 Merge branch 'feat/navbar-desktop-categorias'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m1s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m58s
2026-09-27 05:22:21 +00:00
devopsandClaude Opus 5.5 e6f031a366 feat(storefront): barra de navegación desktop con categorías, personajes, ayuda y ubicación
- Desktop xl+: las 6 categorías raíz directo en la barra con desplegable
  de subcategorías; lg-xl mantiene "Departamentos"; "Personajes"
  (colecciones) y "Ayuda" a la derecha; hamburguesa solo en mobile.
- "Enviar a <nombre> · <ciudad>" (location-store persistido) que precarga
  departamento/ciudad en el checkout; "Hola X / Mi cuenta" y "Cambios y
  devoluciones" en xl+.
- Header compacto al hacer scroll en lg+ (histéresis 160/40px).
- Fix de menús (también en DepartmentsMegaMenu, ya presente en prod): el
  click de mouse ya no cierra el menú recién abierto por hover
  (keepOpenAfterHover) y el hover solo reacciona a pointerType "mouse",
  así el mouseleave sintético de touch no autocierra en tablets.

Revisado por el subagente frontend-critic (3 hallazgos, todos corregidos y
re-verificados) + Playwright contra build de producción local: sin solapes
a 1024/1280/1440/1920, click real, touch, mobile 390/320 sin cambios.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 05:22:21 +00:00
devops 503f56977d chore: promote v1.0.285 to production [skip ci] 2026-09-27 04:20:53 +00:00
devops f8304633c1 chore: release v1.0.285 [skip ci] 2026-09-27 04:20:44 +00:00
devops a25cad6da3 Merge branch 'fix/product-card-galeria-object-contain'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 31s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 18m54s
2026-09-27 04:00:45 +00:00
devopsandClaude Opus 5.5 cbdb44c7dc fix(storefront): galería de tarjetas sin recortes y sin sobrecargar el optimizador
- object-contain en vez de object-cover: las fotos secundarias no son
  cuadradas (Buzz 933x1500, Tristeza 896x1052) y cover les cortaba
  cabeza/pies. Mismo criterio que product-gallery.tsx.
- unoptimized desde la 2da imagen: pasar las ~58 imagenes de las galerias
  por el optimizador de Next dejo un pod de prod OOMKilled (768Mi) ante una
  carga en rafaga. Originales ~165 KB promedio, lazy; la 1ra imagen sigue
  optimizada como antes de la galeria.

Validado con build de produccion local y fotos reales (Playwright: fit
contain medido, fuentes optimizada/directa, suite de galeria completa).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 04:00:44 +00:00
devops 09c756942b chore: promote v1.0.284 to production [skip ci] 2026-09-27 02:17:10 +00:00
devops a9ef0ed00a chore: release v1.0.284 [skip ci] 2026-09-27 02:17:08 +00:00
devops da7f316eb1 Merge branch 'feat/product-card-galeria-swipe'
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 2m4s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 13m27s
2026-09-27 01:37:26 +00:00
devopsandClaude Opus 5.5 123cbbd87a feat(storefront): galería deslizable en las tarjetas de producto del listado
Permite ver todas las imágenes de un producto desde el catálogo y los
carruseles sin entrar a la ficha: swipe con scroll-snap nativo en
móvil, flechas al pasar el mouse en escritorio (pointer-fine) y puntos
de posición. Cada imagen sigue enlazando a la ficha; productos con una
sola imagen no muestran controles. overscroll-behavior-x: contain evita
que el swipe de imágenes mueva el carrusel de productos del inicio.

Validado antes del merge con build de producción local (datos mock) y
Playwright con eventos táctiles reales a 390/320 px y 1280 px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 01:37:25 +00:00
gitea-actions 496ad982f3 chore(gitops): deploy Docs Portal v1.0.283 [skip ci] 2026-09-27 00:31:40 +00:00
devops fa183a7115 Merge branch 'docs/tmux-host-ipv6-confirmado-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 13m45s
2026-09-27 00:16:38 +00:00
devopsandClaude Opus 5.5 4146245ae9 docs(playbooks): IPv6 confirmado tras segundo reinicio, cierre de pendientes
El segundo reinicio real del NAS confirmo que /etc/sysctl.d/99-disable-ipv6.conf
se aplica al arrancar (disable_ipv6=1 sin sysctl -p manual). Se cierran
todos los pendientes del playbook y se reescribe la lista sin sintaxis de
tasklist/tachado, que el portal renderizaba literal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-27 00:16:37 +00:00
gitea-actions a38edfecc8 chore(gitops): deploy Docs Portal v1.0.282 [skip ci] 2026-09-26 19:33:01 +00:00
devops 2566e06abd Merge branch 'docs/tmux-host-ipv6-reinicio-2026-09-26'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 9m35s
2026-09-26 19:22:40 +00:00
devopsandClaude Opus 5.5 c7ac1b52be docs(playbooks): resultado del reinicio real, IPv6 via /etc/sysctl.d en ZimaOS
La prueba de reinicio del NAS (2026-09-26) mostro que /etc/sysctl.conf
persiste pero ZimaOS no lo aplica al arrancar (systemd-sysctl solo lee
/etc/sysctl.d/*.conf, sin symlink 99-sysctl.conf). Se documenta la causa,
el fix con /etc/sysctl.d/99-disable-ipv6.conf, como aplicarlo en caliente
con un contenedor privilegiado de un solo uso, y se cierran los pendientes
ya resueltos (commit de entrypoint.sh, limpieza de etiquetas).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 19:22:39 +00:00
gitea-actions 2e7a3f3d24 chore(gitops): deploy Docs Portal v1.0.281 [skip ci] 2026-09-26 14:51:18 +00:00
devops d9c2037c59 Merge branch 'docs/incidente-tmux-host-2026-09-25'
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m15s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 14m51s
2026-09-26 12:39:31 +00:00
devopsandClaude Opus 5.5 7549dc86d2 docs(playbooks): incidente cadena de 4 fallos en tmux-host (2026-09-25/26)
Documenta la cadena de causas encontradas tras eliminar tmux-host por
"no toma las sesiones remotas": volumenes de auth faltantes al recrear,
set -e matando al supervisor del healthcheck, IPv6 roto en el NAS
(timeout a Anthropic) y, como causa original, el token OAuth vencido.
Agrega la fila de expiracion OAuth a "Que sobrevive a que" en
sesion-tmux-persistente.md con enlace cruzado al playbook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
2026-09-26 12:39:30 +00:00
46 changed files with 2534 additions and 76 deletions

No files matched your search

+8 -1
View File
@@ -234,11 +234,17 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
# --timeout 30m (no 15m): medido en vivo 2026-09-27, el analisis de
# capas de esta imagen (node_modules de Medusa) toma ~14 min en
# el runner del NAS -- con 15m hizo timeout una vez. El paso HIGH
# de abajo reusa ese analisis cacheado (~2 s), por eso no lo
# necesita. No cambia que revisa ni que bloquea.
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity CRITICAL \ --severity CRITICAL \
--exit-code 1 \ --exit-code 1 \
--ignore-unfixed \ --ignore-unfixed \
--timeout 15m0s \ --timeout 30m0s \
--ignorefile "${APP_DIR}/.trivyignore" \ --ignorefile "${APP_DIR}/.trivyignore" \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
@@ -248,6 +254,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity HIGH \ --severity HIGH \
--exit-code 0 \ --exit-code 0 \
--ignore-unfixed \ --ignore-unfixed \
+12 -1
View File
@@ -273,11 +273,20 @@ jobs:
echo "Validando remontaje por parámetros: ${PAGE_FILE}" echo "Validando remontaje por parámetros: ${PAGE_FILE}"
grep -q 'key={`${category}:${collection}:${query}`}' "${PAGE_FILE}" || { # Prefijo, no igualdad exacta: desde franquicias/personajes
# (2026-09-27) la key tambien incluye ?franquicia/?saga/?personaje
# -- lo que importa es que siga remontando por categoria,
# coleccion y busqueda, y ahora tambien por la taxonomia.
grep -q 'key={`${category}:${collection}:${query}' "${PAGE_FILE}" || {
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL." echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
exit 1 exit 1
} }
grep -q 'taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}' "${PAGE_FILE}" || {
echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar franquicia/saga/personaje."
exit 1
}
echo "OK: navegación real del catálogo validada." echo "OK: navegación real del catálogo validada."
- name: Instalar Trivy - name: Instalar Trivy
@@ -420,6 +429,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity CRITICAL \ --severity CRITICAL \
--exit-code 1 \ --exit-code 1 \
--ignore-unfixed \ --ignore-unfixed \
@@ -433,6 +443,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity HIGH \ --severity HIGH \
--exit-code 0 \ --exit-code 0 \
--ignore-unfixed \ --ignore-unfixed \
+2
View File
@@ -231,6 +231,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity CRITICAL \ --severity CRITICAL \
--exit-code 1 \ --exit-code 1 \
--ignore-unfixed \ --ignore-unfixed \
@@ -243,6 +244,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
/tmp/trivy image \ /tmp/trivy image \
--scanners vuln \
--severity HIGH \ --severity HIGH \
--exit-code 0 \ --exit-code 0 \
--ignore-unfixed \ --ignore-unfixed \
@@ -133,6 +133,11 @@ module.exports = defineConfig({
}, },
modules: [ modules: [
// Reseñas de productos (compradores verificados + moderacion del
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
{
resolve: "./src/modules/product-review",
},
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto // ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual // un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js // (confirmado leyendo @medusajs/utils/dist/common/define-config.js
@@ -0,0 +1,140 @@
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { Star } from "@medusajs/icons";
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
import { useCallback, useEffect, useState } from "react";
import { sdk } from "../../lib/sdk";
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
// recien se publican en la tienda cuando se aprueban aca.
type Status = "pending" | "approved" | "rejected";
type Review = {
id: string;
product_title: string;
author_name: string;
rating: number;
title: string | null;
content: string;
status: Status;
order_id: string;
created_at: string;
};
const TABS: { status: Status; label: string }[] = [
{ status: "pending", label: "Pendientes" },
{ status: "approved", label: "Publicadas" },
{ status: "rejected", label: "Rechazadas" },
];
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
function stars(rating: number): string {
return "★".repeat(rating) + "☆".repeat(5 - rating);
}
const ReviewsPage = () => {
const [tab, setTab] = useState<Status>("pending");
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async (status: Status) => {
setLoading(true);
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
setReviews(data.reviews);
setLoading(false);
}, []);
useEffect(() => {
load(tab);
}, [load, tab]);
async function setStatus(review: Review, status: Status) {
setBusyId(review.id);
try {
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
await load(tab);
} catch (error) {
toast.error((error as Error).message);
} finally {
setBusyId(null);
}
}
async function remove(review: Review) {
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
setBusyId(review.id);
try {
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
toast.success("Reseña borrada");
await load(tab);
} catch (error) {
toast.error((error as Error).message);
} finally {
setBusyId(null);
}
}
return (
<Container className="divide-y p-0">
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
<div>
<Heading level="h2">Reseñas</Heading>
<Text size="small" className="text-ui-fg-subtle">
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
</Text>
</div>
<div className="flex gap-2">
{TABS.map((item) => (
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
{item.label}
</Button>
))}
</div>
</div>
{loading ? (
<div className="px-6 py-8"><Text>Cargando…</Text></div>
) : reviews.length === 0 ? (
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
) : (
reviews.map((review) => (
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-2xl">
<div className="flex flex-wrap items-center gap-2">
<Text weight="plus">{review.product_title}</Text>
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
</div>
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
<Text size="small" className="mt-2 text-ui-fg-subtle">
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
</Text>
</div>
<div className="flex shrink-0 gap-2">
{review.status !== "approved" && (
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
)}
{review.status !== "rejected" && (
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
)}
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
</div>
</div>
))
)}
</Container>
);
};
export const config = defineRouteConfig({
label: "Reseñas",
icon: Star,
});
export default ReviewsPage;
@@ -115,6 +115,15 @@ function normalizeTitleForMatch(title: string): string {
.toLowerCase(); .toLowerCase();
} }
// Categorias/colecciones: ademas de lo anterior, ignora tildes y eñes
// ("Figuras de accion" == "Figuras de acción"). Desde 2026-09-27 los
// nombres reales en Medusa llevan tildes; sin esto, un CSV escrito sin
// tildes (o viejo) crearia una categoria DUPLICADA en vez de reusar la
// existente. Los titulos de producto siguen con normalizeTitleForMatch.
function normalizeNameForMatch(name: string): string {
return normalizeTitleForMatch(name).normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function slugify(title: string, uniquePart: string): string { function slugify(title: string, uniquePart: string): string {
const base = title const base = title
.normalize("NFD") .normalize("NFD")
@@ -166,7 +175,7 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
// ofrece descargar y lo que realmente se procesa. // ofrece descargar y lo que realmente se procesa.
const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos"; const header = "titulo,descripcion,precio,categoria,coleccion,cantidad,fotos";
const example = 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"'; '"Muñeco Buzz Lightyear","Figura articulada de 30cm, edición coleccionista.",89900,"Figuras de acción","Toy Story",3,"https://ejemplo.com/foto1.jpg;https://ejemplo.com/foto2.jpg"';
const csv = `${header}\n${example}\n`; const csv = `${header}\n${example}\n`;
res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"'); res.setHeader("Content-Disposition", 'attachment; filename="plantilla-importacion.csv"');
@@ -217,10 +226,10 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
} }
const categoryByName = new Map( const categoryByName = new Map(
categories.filter((c) => c.name).map((c) => [normalizeTitleForMatch(c.name), c.id]), categories.filter((c) => c.name).map((c) => [normalizeNameForMatch(c.name), c.id]),
); );
const collectionByName = new Map( const collectionByName = new Map(
collections.filter((c) => c.title).map((c) => [normalizeTitleForMatch(c.title), c.id]), collections.filter((c) => c.title).map((c) => [normalizeNameForMatch(c.title), c.id]),
); );
const productByTitle = new Map( const productByTitle = new Map(
existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]), existingProducts.filter((p) => p.title).map((p) => [normalizeTitleForMatch(p.title), p]),
@@ -289,7 +298,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario"); if (!inventoryItemId) throw new Error("No se pudo crear el registro de inventario");
const categoryName = row.categoria?.trim(); const categoryName = row.categoria?.trim();
let categoryId = categoryName ? categoryByName.get(normalizeTitleForMatch(categoryName)) : undefined; let categoryId = categoryName ? categoryByName.get(normalizeNameForMatch(categoryName)) : undefined;
if (categoryName && !categoryId) { if (categoryName && !categoryId) {
const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({ const { result: createdCategories } = await createProductCategoriesWorkflow(req.scope).run({
input: { input: {
@@ -299,11 +308,11 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
categoryId = createdCategories[0].id; categoryId = createdCategories[0].id;
categoryByName.set(normalizeTitleForMatch(categoryName), categoryId); categoryByName.set(normalizeNameForMatch(categoryName), categoryId);
} }
const collectionName = row.coleccion?.trim(); const collectionName = row.coleccion?.trim();
let collectionId = collectionName ? collectionByName.get(normalizeTitleForMatch(collectionName)) : undefined; let collectionId = collectionName ? collectionByName.get(normalizeNameForMatch(collectionName)) : undefined;
if (collectionName && !collectionId) { if (collectionName && !collectionId) {
const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({ const { result: createdCollections } = await createCollectionsWorkflow(req.scope).run({
input: { input: {
@@ -313,7 +322,7 @@ export async function POST(req: MedusaRequest<{ csv: string }>, res: MedusaRespo
}, },
}); });
collectionId = createdCollections[0].id; collectionId = createdCollections[0].id;
collectionByName.set(normalizeTitleForMatch(collectionName), collectionId); collectionByName.set(normalizeNameForMatch(collectionName), collectionId);
} }
await createProductsWorkflow(req.scope).run({ await createProductsWorkflow(req.scope).run({
@@ -0,0 +1,24 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
const STATUSES = ["pending", "approved", "rejected"] as const;
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
const status = req.body?.status;
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
res.json({ review });
}
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
await reviews.deleteProductReviews(req.params.id);
res.json({ id: req.params.id, deleted: true });
}
@@ -0,0 +1,25 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /admin/reviews?status=pending -- cola de moderacion para la
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const status = typeof req.query.status === "string" ? req.query.status : undefined;
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
order: { created_at: "DESC" },
take: 200,
});
const productIds = [...new Set(list.map((review) => review.product_id))];
const titles: Record<string, string> = {};
if (productIds.length) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
for (const product of products) titles[product.id] = product.title;
}
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
}
@@ -1,4 +1,4 @@
import { defineMiddlewares } from "@medusajs/medusa" import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
// GET /store/products descarta en silencio la relacion `categories` // GET /store/products descarta en silencio la relacion `categories`
// aunque `/admin/products` si la expone -- confirmado en vivo en la // aunque `/admin/products` si la expone -- confirmado en vivo en la
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links // https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
export default defineMiddlewares({ export default defineMiddlewares({
routes: [ routes: [
// Reseñas: crear una y ver las propias exige cliente autenticado --
// la verificacion de compra usa auth_context.actor_id (ver
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
// siguen publicos.
{
matcher: "/store/reviews",
method: ["POST"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{
matcher: "/store/reviews/me",
method: ["GET"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{ {
matcher: "/store/products", matcher: "/store/products",
middlewares: [ middlewares: [
@@ -0,0 +1,23 @@
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
// estado), para que "Mis pedidos" sepa que productos ya califico y en
// que estado de moderacion quedo cada una.
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
res.json({
reviews: mine.map((review) => ({
id: review.id,
product_id: review.product_id,
order_id: review.order_id,
rating: review.rating,
title: review.title,
content: review.content,
status: review.status,
created_at: review.created_at,
})),
});
}
@@ -0,0 +1,106 @@
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
// mas promedio y cantidad. Publica (solo publishable key).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
if (!productId) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(
{ product_id: productId, status: "approved" },
{ order: { created_at: "DESC" }, take: 50 },
);
const count = list.length;
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
res.json({
reviews: list.map((review) => ({
id: review.id,
author_name: review.author_name,
rating: review.rating,
title: review.title,
content: review.content,
created_at: review.created_at,
verified_purchase: true,
})),
average: Math.round(average * 10) / 10,
count,
});
}
type CreateReviewBody = {
product_id?: string;
order_id?: string;
rating?: number;
title?: string;
content?: string;
};
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
// si el pedido indicado es SUYO, no esta cancelado y contiene el
// producto: eso es lo que respalda el sello "Compra verificada". Nace
// "pending" hasta que el dueño la apruebe en el admin.
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
const customerId = req.auth_context.actor_id;
const { product_id, order_id, title } = req.body ?? {};
const rating = Number(req.body?.rating);
const content = (req.body?.content ?? "").trim();
if (!product_id || !order_id) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
}
if (content.length < 10 || content.length > 2000) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: orders } = await query.graph({
entity: "order",
fields: ["id", "customer_id", "status", "items.product_id"],
filters: { id: order_id },
});
const order = orders[0];
const purchased =
order &&
order.customer_id === customerId &&
order.status !== "canceled" &&
(order.items ?? []).some((item) => item?.product_id === product_id);
if (!purchased) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
if (existing.length > 0) {
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
}
const { data: customers } = await query.graph({
entity: "customer",
fields: ["first_name", "last_name"],
filters: { id: customerId },
});
const customer = customers[0];
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
const review = await reviews.createProductReviews({
product_id,
order_id,
customer_id: customerId,
author_name: authorName,
rating,
title: title?.trim() || null,
content,
status: "pending",
});
res.status(201).json({ review: { id: review.id, status: review.status } });
}
@@ -0,0 +1,21 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
// por producto, para las estrellas de las tarjetas del listado (una sola
// llamada para todo el catalogo en vez de una por tarjeta).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
const summary: Record<string, { average: number; count: number }> = {};
for (const review of approved) {
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
entry.average += review.rating;
entry.count += 1;
}
for (const entry of Object.values(summary)) {
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
}
res.json({ summary });
}
@@ -0,0 +1,8 @@
import { Module } from "@medusajs/framework/utils"
import ProductReviewModuleService from "./service"
export const PRODUCT_REVIEW_MODULE = "productReview"
export default Module(PRODUCT_REVIEW_MODULE, {
service: ProductReviewModuleService,
})
@@ -0,0 +1,262 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": true,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"customer_id": {
"name": "customer_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"order_id": {
"name": "order_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"author_name": {
"name": "author_name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"rating": {
"name": "rating",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "integer"
},
"title": {
"name": "title",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"content": {
"name": "content",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"status": {
"name": "status",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": "'pending'",
"comment": null,
"enumItems": [
"pending",
"approved",
"rejected"
],
"mappedType": "enum"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "datetime"
}
},
"name": "product_review",
"schema": "public",
"indexes": [
{
"keyName": "IDX_product_review_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_status",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_customer_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_customer_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "product_review_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
}
@@ -0,0 +1,18 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260927062845 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "product_review" cascade;`);
}
}
@@ -0,0 +1,25 @@
import { model } from "@medusajs/framework/utils"
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
// solo compradores verificados -- customer_id + order_id se validan en
// POST /store/reviews contra un pedido real del cliente que contenga el
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
// dueño las aprueba desde el admin (pagina "Reseñas").
export const ProductReview = model
.define("product_review", {
id: model.id({ prefix: "prev" }).primaryKey(),
product_id: model.text(),
customer_id: model.text(),
order_id: model.text(),
author_name: model.text(),
rating: model.number(),
title: model.text().nullable(),
content: model.text(),
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
})
.indexes([
{ on: ["product_id", "status"] },
{ on: ["customer_id"] },
// Una reseña por cliente por producto.
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
])
@@ -0,0 +1,6 @@
import { MedusaService } from "@medusajs/framework/utils"
import { ProductReview } from "./models/product-review"
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
export default ProductReviewModuleService
@@ -408,6 +408,14 @@ después del swap.
| `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 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 | | `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 | | Falla de red real del host/NAS | N/A | N/A — `healthcheck.sh` la detecta y notifica, pero no la corrige (fuera de su alcance) | N/A |
| Expiración del token OAuth de Claude Code | Sí — el proceso `claude` sigue vivo en el pane, pero muestra `Login expired` y Remote Control queda desconectado (`OAuth token unavailable`) | **No** — requiere `/login` manual (una vez alcanza: el volumen de credenciales es compartido) y después `/remote-control` en **cada** sesión ya abierta. Recrear el contenedor **no** lo arregla | Sí, una vez reautenticado — misma sesión, mismo proceso |
!!! warning "Incidente relacionado"
La fila de expiración del token OAuth salió del
[incidente de la cadena de 4 fallos en `tmux-host` (2026-09-25/26)](../playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md).
El síntoma "no aparecen las sesiones remotas" llevó a eliminar el
contenedor, aunque la causa real era un token vencido. **Antes de
tocar el contenedor, mirá el pane de cada sesión.**
## Validación en vivo ## Validación en vivo
@@ -0,0 +1,385 @@
# Incidente: cadena de 4 fallos en `tmux-host` (2026-09-25/26)
| | |
|---|---|
| **Ventana del incidente** | Noche del 2026-09-25 al 26 (hora local); en `health.log`, de 2026-09-26 04:11Z a ~06:20Z |
| **Servicio afectado** | Contenedor `tmux-host`: las sesiones `lab` y `cloud-practice` de Claude Code y su Remote Control desde claude.ai (ver [Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)) |
| **Impacto** | Las sesiones remotas no aparecían en claude.ai. Mientras se intentaba arreglar, se perdió el login y la confianza del folder, el supervisor del healthcheck quedó muerto y `claude` no podía conectarse a Anthropic |
| **Disparador** | Se eliminó el contenedor `tmux-host` a mano creyendo que era la causa |
## Contexto / disparador
El síntoma inicial era uno solo: **"no toma las sesiones remotas"**. Las
sesiones de Claude Code dentro de `tmux-host` no aparecían en claude.ai.
Parecía un problema del contenedor, así que se eliminó `tmux-host` para
recrearlo desde cero.
En realidad ese síntoma tenía una causa que no tenía nada que ver con el
contenedor: el token OAuth estaba vencido (causa **d**, más abajo). Al
recrear el contenedor, la mitad de la noche se fue en resolver tres fallos
reales que *aparecieron* en el camino (causas **a**, **b** y **c**). Cada
uno se encontró recién después de resolver el anterior. Recién al final
apareció la causa original.
!!! info "Fuentes de la evidencia"
Este playbook se escribió desde dentro del `tmux-host` ya recuperado.
Los datos marcados **(verificado)** se confirmaron contra el estado
real del sistema al escribirlo: `docker inspect`, `docker images`,
`health.log`, `sysctl`, `curl`, `git diff` y los panes de tmux. Los
marcados **(reportado)** vienen del relato de la sesión de
recuperación. Esa sesión se hizo desde fuera del contenedor y no dejó
transcript ni historial de shell que se pueda revisar.
---
## Causa a — Contenedor recreado sin los volúmenes de autenticación
**Síntoma.** Al recrear `tmux-host`, `claude` volvía a pedir login y el
diálogo de confianza del folder. `--continue` no encontraba ninguna
conversación previa y arrancaba una en blanco.
**Diagnóstico.** El `docker run` que se usó para recrear el contenedor
se armó de memoria. Tenía solo 3 de los 5 volúmenes documentados en
[Sesión tmux persistente](../arquitectura/sesion-tmux-persistente.md)
**(reportado)**. Faltaban justamente los dos de autenticación y estado
de Claude Code:
| Volumen faltante | Qué se perdía |
|---|---|
| `/DATA/AppData/k3d-lab-v3/claude-code-home` → `/root/.claude` | Credenciales OAuth, historial de conversaciones (por eso `--continue` no encontraba nada), plugins y memoria |
| `/DATA/AppData/k3d-lab-v3/claude-code-home.json` → `/root/.claude.json` | Confianza del folder y el flag de Remote Control |
Sin esos mounts, `/root/.claude*` vive en el filesystem efímero del
contenedor, así que cada recreación arrancaba "de fábrica". El contenedor
se reconstruyó varias veces con el mismo comando incompleto antes de
comparar contra el doc de arquitectura **(reportado)**. `health.log`
muestra las sesiones recreándose desde cero a las 04:11Z, 04:40Z, 04:49Z,
05:03Z, 05:18Z, 06:08Z y 06:10Z **(verificado)**.
**Fix.** Recrear el contenedor con el `docker run` completo de la
sección *Contenedor: `tmux-host`* del doc de arquitectura. Los 5 volúmenes
van juntos, sin excepciones.
**Validación en vivo (verificado).** `docker inspect tmux-host` sobre el
contenedor final (creado `2026-09-26T06:10:55Z`, imagen
`tmux-host:2026-09-26-fix-real`) lista los 5 mounts:
```
/var/run/docker.sock -> /var/run/docker.sock
/DATA/AppData/k3d-lab-v3/claude-code-home -> /root/.claude
/DATA/AppData/k3d-lab-v3/claude-code-home.json -> /root/.claude.json
/DATA -> /DATA
/media/HDD-Storage -> /media/HDD-Storage
```
La sesión `lab` retomó la conversación anterior con `--continue`, sin
pedir de nuevo la confianza del folder.
!!! warning "Lección"
**Antes de improvisar un `docker run` de memoria, leer siempre el doc
de arquitectura que ya existe.** El comando completo y el motivo de
cada volumen ya estaban documentados en `sesion-tmux-persistente.md`.
El tiempo perdido reconstruyendo con un comando incompleto salió de no
abrirlo primero.
---
## Causa b — `set -e` heredado mataba al supervisor del healthcheck
**Síntoma.** `healthcheck.sh` corre en background y no tenía quién lo
relanzara si moría. Para cubrir eso se agregó a `entrypoint.sh` un
supervisor: un loop que lo relanza tras 10 s. Aun así, cuando
`healthcheck.sh` terminaba con error, no se relanzaba y el contenedor
seguía vivo sin healthcheck.
**Diagnóstico.** `entrypoint.sh` tenía `set -eu` desde antes. El
supervisor corre como función en un subshell (`supervise_healthcheck &`)
y hereda el `-e`. Cuando `healthcheck.sh` devolvía un código distinto de
cero, `set -e` mataba al propio subshell del supervisor *en esa misma
línea*, antes de llegar al `echo WARN` y al `sleep 10` que lo relanzaban.
Es decir, el supervisor moría justo en el único caso para el que existía.
**Paso en falso: el fix que nunca llegó al disco.** La primera
"corrección" (`set -eu` → `set -u`) nunca se guardó en el archivo, y la
imagen se reconstruyó dos veces con el contenido viejo **(reportado)**.
La evidencia sigue en el host **(verificado)**: las etiquetas
`tmux-host:2026-09-25-supervised` y `tmux-host:2026-09-25-supervised-v2`
apuntan a la **misma imagen, `ad7f6eff9537`**. Docker reutilizó todas las
capas porque el archivo no había cambiado. Recién después de confirmar
con `cat entrypoint.sh` que el cambio no estaba, se guardó de verdad y se
construyó `tmux-host:2026-09-26-fix-real` (`7e4c94bf0bc8`, 05:03Z).
**Fix.** En `scripts/tmux-host/entrypoint.sh`:
```diff
-set -eu
+set -u
-/usr/local/bin/healthcheck.sh &
+supervise_healthcheck() {
+ while true; do
+ /usr/local/bin/healthcheck.sh
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN: healthcheck.sh salio inesperadamente (exit=$?) -- relanzando en 10s" \
+ >> /DATA/AppData/k3d-lab-v3/tmux-host/logs/health.log 2>/dev/null || true
+ sleep 10
+ done
+}
+supervise_healthcheck &
exec tail -f /dev/null
```
**Validación en vivo (verificado).** `health.log` registra el supervisor
actuando en un caso real. `healthcheck.sh` murió por SIGKILL, se registró
el aviso y se relanzó solo 10 segundos después:
```
2026-09-26T05:04:17Z WARN: healthcheck.sh salio inesperadamente (exit=137) -- relanzando en 10s
[2026-09-26T05:04:27Z] === healthcheck inicio (intervalo=1800s) ===
```
!!! warning "Lección"
**Verificar el archivo en disco (`cat`, `git diff`) ANTES de
reconstruir la imagen.** No hay que asumir que el editor guardó el
cambio. Dos etiquetas de imagen con el mismo ID indican que el build
no tuvo cambios.
---
## Causa c — IPv6 roto en el NAS: timeout de conexión a Anthropic
**Síntoma.** Con el contenedor ya bien montado, `claude` fallaba al
arrancar con `Unable to connect to Anthropic services` a los ~10 s.
**Diagnóstico** con `curl -v https://api.anthropic.com/` **(reportado)**:
1. El resolver devolvía primero la dirección IPv6 de `api.anthropic.com`.
2. La ruta IPv6 del NAS no sale a internet: el intento fallaba al
instante con `Network unreachable`.
3. El fallback automático a IPv4 terminaba funcionando, pero la conexión
completa tardaba **~14 s**. Eso es más que el timeout interno de
Claude Code (~10 s), así que el CLI se rendía antes de que el
fallback llegara a conectar.
**Primer intento: parche parcial.**
`NODE_OPTIONS=--dns-result-order=ipv4first` le indica a Node que prefiera
IPv4 al resolver. Ayudó, pero no resultó confiable en todos los casos
**(reportado)**, y además solo corregía el proceso de Node, no la red del
host. Se descartó y no quedó en la imagen: el contenedor final no tiene
`NODE_OPTIONS` en su entorno **(verificado)**.
**Fix de raíz.** Deshabilitar IPv6 a nivel de sistema operativo del NAS,
en caliente y persistente:
```bash
# En caliente
sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1
# Persistente: /etc/sysctl.d/99-disable-ipv6.conf del host
# (NO /etc/sysctl.conf -- ver "Validación tras reinicio real" abajo)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
```
Esto se tiene que hacer **en el host, no dentro del contenedor**.
`tmux-host` corre con `--network host`, así que comparte el network
namespace del NAS. Docker no permite fijar sysctls de red
(`--sysctl net.*`) en un contenedor con `--network host`, porque
cambiarían la red de todo el host.
**Validación en vivo (verificado, 2026-09-26 ~06:40Z):**
```
$ sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
$ getent ahosts api.anthropic.com
160.79.104.10 STREAM api.anthropic.com # solo IPv4, sin AAAA
$ curl -w 'connect=%{time_connect}s total=%{time_total}s' https://api.anthropic.com/
ip=160.79.104.10 connect=0.035s total=0.65s # antes: ~14 s
```
`/etc/sysctl.conf` en el host contiene las dos líneas (escrito
2026-09-26 05:55:53Z). En ZimaOS, `/` es un `squashfs` de solo lectura,
pero `/etc` es un overlay con capa de escritura en
`/mnt/overlay/upper_etc` (ext4 en `/dev/sdc7`), así que el archivo
persiste.
### Validación tras reinicio real: `/etc/sysctl.conf` no alcanzaba (2026-09-26)
Se reinició el NAS a propósito (~17:13Z) para probar la persistencia.
Resultado **(verificado)**:
- `/etc/sysctl.conf` **sí** sobrevivió al reinicio con las dos líneas
(el overlay de `/etc` persiste).
- Pero `sysctl net.ipv6.conf.all.disable_ipv6` volvió a dar **`0`**: el
archivo existía y **nunca se aplicó** al arrancar.
**Causa.** En ZimaOS, `systemd-sysctl` solo carga `/etc/sysctl.d/*.conf`
(y `/usr/lib/sysctl.d/`, `/lib/sysctl.d/`). Otras distros traen un
symlink `/etc/sysctl.d/99-sysctl.conf -> ../sysctl.conf` que hace que
`/etc/sysctl.conf` también se lea al arrancar. **ZimaOS no lo trae**, así
que escribir en `/etc/sysctl.conf` funciona solo en caliente (con
`sysctl -p`) y no sobrevive un reinicio. Nada avisa del problema: el
archivo está, el contenido es correcto y el valor igual vuelve a `0`.
**Por qué no se rompió enseguida.** Tras el reinicio el host no tenía
ninguna IPv6 global utilizable, así que `getaddrinfo` no devolvía la AAAA
de `api.anthropic.com` y todo salía por IPv4. Pero `curl -6` seguía
fallando (`Could not connect to server`). Si el router vuelve a entregar
una IPv6 global por SLAAC, vuelve el timeout. Funcionaba por casualidad,
no porque estuviera corregido.
**Fix.** Mover la configuración a un drop-in que ZimaOS sí lee:
`/etc/sysctl.d/99-disable-ipv6.conf` (mismas dos líneas).
Para aplicarlo en caliente sin reiniciar: dentro de `tmux-host`,
`/proc/sys` es de solo lectura (`sysctl -w` → `Read-only file system`).
Hace falta un contenedor privilegiado de un solo uso en la red del host,
o `sudo sysctl -p` directo en el host:
```bash
docker run --rm --privileged --network host \
-v /etc/sysctl.d:/etc/sysctl.d:ro \
alpine sysctl -p /etc/sysctl.d/99-disable-ipv6.conf
```
**Validado en vivo (verificado, 2026-09-26 ~19:15Z):**
`disable_ipv6 = 1` en `all` y `default`, ninguna dirección IPv6 global en
el host y `curl` a `api.anthropic.com` por IPv4 (`connect=0.035s`).
!!! warning "Lección"
"El archivo de configuración persistió" no es lo mismo que "la
configuración se aplica al arrancar". La única validación real de
algo persistente es **reiniciar y medir el valor efectivo**, no
revisar que el archivo siga ahí.
!!! success "Confirmado con un segundo reinicio real (2026-09-26 ~19:37Z)"
Tras reiniciar el NAS de nuevo, sin correr ningún `sysctl -p` a mano:
`disable_ipv6 = 1` en `all` y `default`, y `curl` a
`api.anthropic.com` por IPv4 (`connect=0.035s`). El drop-in de
`/etc/sysctl.d/` **sí** se aplica al arrancar. El único IPv6 que queda
es la ULA de `virbr0` (red interna de libvirt, `NO-CARRIER`,
`tentative`), que no sale a internet y no afecta.
Sigue sin verificarse si una **actualización de ZimaOS** pisa
`upper_etc`. Después de cada update del SO, revisar
`sysctl net.ipv6.conf.all.disable_ipv6`.
---
## Causa d — Token OAuth vencido: Remote Control desconectado sin recuperación automática
**Síntoma.** Con todo lo anterior resuelto, las sesiones remotas
*seguían* sin aparecer en claude.ai. Este era el síntoma original de la
noche.
**Diagnóstico (verificado).** Ambos procesos `claude --remote-control
--continue` estaban corriendo (`ps`), y el healthcheck había recreado
las dos sesiones sin problema (`health.log`, 06:10:58Z). Así que el
auto-arranque funcionaba. El contenido de los panes de tmux mostró la
causa:
- Sesión `lab`: `Login expired · Please run /login`
- Sesión `cloud-practice`:
`Remote Control disconnected — OAuth token unavailable — run /login to restore Remote Control`
El token OAuth de Claude Code había vencido. No había ningún problema de
red, de contenedor ni de volúmenes. **Esta causa no tenía relación con
ninguna de las tres anteriores.** Eliminar el contenedor nunca podría
haberla resuelto.
**Fix.**
1. `/login` en **una** sesión. Las credenciales viven en
`/root/.claude/.credentials.json`, que es el volumen compartido por
todas las sesiones del contenedor.
2. `/remote-control` → **1. Enable Remote Control** en **cada** sesión ya
abierta. Un proceso `claude` que ya estaba corriendo **no** se
reconecta solo al renovarse el token. Hay que reactivarlo en cada uno.
Para una sesión a la que no estás adjunto:
```bash
tmux send-keys -t cloud-practice '/remote-control' Enter
# aparece el menú con "1. Enable Remote Control" ya seleccionado
tmux send-keys -t cloud-practice Enter
```
**Validación en vivo (verificado).** El pane de `cloud-practice` pasó a
mostrar `/remote-control is active · Continue here, on your phone, or at
https://claude.ai/code/session_…`, y en `lab` se activó con
`/remote-control` después del `/login`. Las dos sesiones volvieron a
aparecer en claude.ai.
!!! tip "Diferencia clave con las otras causas"
El healthcheck **sí** recrea sola una sesión tmux caída, y `--continue`
retoma la conversación. Un **token vencido no se recupera solo**: hace
falta que una persona se reautentique (`/login`) y reactive Remote
Control (`/remote-control`) en cada sesión abierta. Recrear el
contenedor no lo arregla. Con los volúmenes bien montados, el
contenedor nuevo lee el mismo token vencido.
---
## Cronología resumida
Horas en UTC, según `health.log`, `docker images` y los timestamps de
archivos cuando existen.
| Hora (UTC) | Qué pasó | ¿Resolvió el síntoma original? |
|---|---|---|
| antes de 04:11 | Remote Control se desconecta porque vence el token OAuth (causa **d**, todavía sin identificar) | — |
| ~04:10 | Se elimina `tmux-host` creyendo que es la causa | No |
| 04:11 → 05:18 | Varias recreaciones con un `docker run` de memoria, sin los mounts de auth: se pide login y confianza de nuevo y `--continue` arranca vacío (causa **a**) | No, y además empeoró |
| 04:39 | Se agrega el supervisor del healthcheck; builds `supervised` y `supervised-v2`, **misma imagen `ad7f6eff9537`** (el fix de `set -e` no estaba guardado) | No |
| 05:03 | Se confirma con `cat`, se guarda de verdad y se construye `fix-real` (`7e4c94bf0bc8`) con `set -u` (causa **b**) | No |
| 05:04 | El supervisor relanza `healthcheck.sh` tras un `exit=137`: primera prueba real | No |
| hasta ~05:55 | `claude` falla con `Unable to connect to Anthropic services`; con `curl -v` se ve IPv6 unreachable y ~14 s de fallback; se prueba `NODE_OPTIONS` (parcial) | No |
| 05:55 | Se deshabilita IPv6 en el host, en caliente y en `/etc/sysctl.conf` (causa **c**) | No |
| 06:10 | Contenedor final: `fix-real` + los 5 volúmenes, recreado según el doc de arquitectura (causa **a** cerrada) | No: Remote Control sigue desconectado |
| ~06:20 | Los panes muestran `Login expired` / `OAuth token unavailable` → `/login` + `/remote-control` en cada sesión (causa **d**) | **Sí** |
**Camino corto para la próxima vez.** Si "no aparecen las sesiones
remotas", **antes de tocar el contenedor** mirar el pane de cada sesión:
```bash
for s in $(tmux ls -F '#S'); do echo "== $s"; tmux capture-pane -p -t "$s" | grep -v '^\s*$' | tail -5; done
```
Si aparece `Login expired` u `OAuth token unavailable`, la solución es
`/login` + `/remote-control`. No hace falta nada más.
## Lección general
Un solo síntoma ("no toma las sesiones remotas") puede tener **varias
causas independientes superpuestas**. En este caso, tres de ellas las
provocó el propio intento de arreglo. Que la primera causa real
encontrada se haya resuelto no significa que era *la* causa del síntoma.
Lo que destrabó el incidente fue seguir verificando con evidencia
concreta después de cada fix, en vez de asumir que "ya quedó": el
contenido de los panes, `health.log`, `docker inspect`, `docker images`
(IDs repetidos) y `curl -v`. Cada una de las cuatro causas se confirmó
con un dato observable, no por deducción.
## Pendientes
Todos cerrados el 2026-09-26:
- **Hecho: commitear el fix de `entrypoint.sh`.** Mergeado a `main` del
repo `scripts` (`510cc41`). Antes se verificó que `entrypoint.sh` y
`healthcheck.sh` del repo fueran idénticos (md5) a los de la imagen
`fix-real` en producción.
- **Hecho: validar IPv6 tras un reinicio real.** El primer reinicio
**falló**: `/etc/sysctl.conf` no se aplica al arrancar en ZimaOS. Se movió
a `/etc/sysctl.d/99-disable-ipv6.conf`, y un segundo reinicio confirmó
que ahora sí se aplica solo (ver la causa c).
- **Hecho: limpiar las etiquetas de imagen viejas de `tmux-host`.** Quedan
solo `2026-09-26-fix-real` (vigente) y `2026-09-20-uv` (rollback).
Queda una sola vigilancia abierta, sin fecha: después de cada actualización
de ZimaOS, revisar que `/etc/sysctl.d/99-disable-ipv6.conf` siga existiendo
y que `disable_ipv6` siga en `1`.
+1
View File
@@ -114,6 +114,7 @@ nav:
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md - Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md - Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md - Blackhole MTU cross-node: playbooks/incidente-mtu-blackhole-cross-node-2026-08.md
- "Cadena de fallos tmux-host": playbooks/incidente-tmux-host-cadena-fallos-2026-09-25.md
- "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md - "Migración a platform-templates": playbooks/migracion-platform-templates-2026-08.md
- Aprendizajes: - Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md - Notas sueltas: aprendizajes/notas-sueltas.md
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false runAsNonRoot: false
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.280 image: gitea.cruzcloud.net/devops/docs-portal:v1.0.294
# El base saca TODAS las capabilities. Probado en vivo y # El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE # revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al # de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
@@ -26,11 +26,11 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
env: env:
# Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml # Ver comentario en workloads/ecommerce/frontend/patches/deployment.yaml
- name: APP_VERSION - name: APP_VERSION
value: v1.0.275 value: v1.0.293
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa
# Backend COMPARTIDO con Producción a propósito (decisión del # Backend COMPARTIDO con Producción a propósito (decisión del
@@ -0,0 +1,11 @@
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
export async function GET(request: Request) {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
try {
return Response.json({ orders: await getMyOrders(header.slice(7)) });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
}
}
@@ -0,0 +1,11 @@
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
export async function GET(request: Request) {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
try {
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
} catch (error) {
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
}
}
@@ -0,0 +1,30 @@
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
function bearer(request: Request): string | null {
const header = request.headers.get("authorization") ?? "";
return header.startsWith("Bearer ") ? header.slice(7) : null;
}
export async function GET(request: Request) {
const productId = new URL(request.url).searchParams.get("product_id");
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
try {
return Response.json(await getProductReviews(productId));
} catch {
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
// reseñas" en vez de romperse.
return Response.json({ reviews: [], average: 0, count: 0 });
}
}
export async function POST(request: Request) {
const token = bearer(request);
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
const body = await request.json().catch(() => ({}));
try {
return Response.json(await createReview(token, body), { status: 201 });
} catch (error) {
const status = (error as { status?: number }).status ?? 400;
return Response.json({ error: (error as Error).message }, { status });
}
}
@@ -0,0 +1,9 @@
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
export async function GET() {
try {
return Response.json({ summary: await getReviewSummary() });
} catch {
return Response.json({ summary: {} });
}
}
+17 -2
View File
@@ -11,6 +11,13 @@ type CatalogPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}; };
// Sin tildes/eñes ni mayusculas: los links viejos (o escritos a mano)
// como ?category=Munecas y peluches siguen llevando a la categoria real
// "Muñecas y peluches" (renombrada con tildes el 2026-09-27).
function normalizeName(value: string): string {
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
}
function single(value: string | string[] | undefined): string { function single(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] ?? "" : value ?? ""; return Array.isArray(value) ? value[0] ?? "" : value ?? "";
} }
@@ -19,9 +26,16 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const params = await searchParams; const params = await searchParams;
const [products, categories] = await Promise.all([getProducts(), getCategories()]); const [products, categories] = await Promise.all([getProducts(), getCategories()]);
const category = single(params.category) || "Todos"; const requestedCategory = single(params.category) || "Todos";
const category =
categories.find((node) => normalizeName(node.name) === normalizeName(requestedCategory))?.name ?? requestedCategory;
const collection = single(params.collection); const collection = single(params.collection);
const query = single(params.q); const query = single(params.q);
const taxonomy = {
franquicia: single(params.franquicia),
saga: single(params.saga),
personaje: single(params.personaje),
};
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y // Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada // morrales"), pero ningun producto tiene esa categoria asignada
@@ -39,13 +53,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
return ( return (
<CatalogClient <CatalogClient
key={`${category}:${collection}:${query}`} key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
products={products} products={products}
categories={categories} categories={categories}
categoryGroups={categoryGroups} categoryGroups={categoryGroups}
initialCategory={category} initialCategory={category}
initialCollection={collection} initialCollection={collection}
initialQuery={query} initialQuery={query}
initialTaxonomy={taxonomy}
/> />
); );
} }
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { OrdersClient } from "@/components/account/orders-client";
export const metadata: Metadata = {
title: "Mis pedidos",
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
robots: { index: false },
};
export default function OrdersPage() {
return <OrdersClient />;
}
@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { CardRating } from "@/components/product/card-rating";
import { ProductCard } from "@/components/product/product-card"; import { ProductCard } from "@/components/product/product-card";
import { ProductReviews } from "@/components/product/product-reviews";
import { ProductCarousel } from "@/components/product/product-carousel"; import { ProductCarousel } from "@/components/product/product-carousel";
import { ProductGallery } from "@/components/product/product-gallery"; import { ProductGallery } from "@/components/product/product-gallery";
import { ProductPurchase } from "@/components/product/product-purchase"; import { ProductPurchase } from "@/components/product/product-purchase";
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="min-w-0"> <div className="min-w-0">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span> <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> <h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
<CardRating productId={product.id} href="#resenas" />
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p> <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 -- {/* Antes escondido detrás de un click en el acordeón --
siempre visible, estilo Amazon, con los mismos datos siempre visible, estilo Amazon, con los mismos datos
@@ -87,6 +90,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent> <AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<ProductReviews productId={product.id} />
</div> </div>
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia {/* Buy box estilo Amazon: precio/variantes/carrito en su propia
tarjeta, sticky desde xl mientras el resto de la pagina tarjeta, sticky desde xl mientras el resto de la pagina
@@ -65,7 +65,7 @@ spec:
memory: 64Mi memory: 64Mi
- name: migrations - name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.265 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.295
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.265 image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.295
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
envFrom: envFrom:
- configMapRef: - configMapRef:
@@ -46,7 +46,7 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2> <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> <p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3"> <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 asChild variant="outline"><Link href="/cuenta/pedidos" onClick={() => onOpenChange(false)}><PackageCheck />Mis pedidos y reseñas</Link></Button>
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button> <Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div> </div>
</> </>
@@ -0,0 +1,276 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Clock, PackageCheck, Star } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { formatMoney } from "@/lib/utils";
import { useCustomerStore } from "@/store/customer-store";
type OrderItem = { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number };
type Order = { id: string; displayId: number | null; createdAt: string; status: string; total: number | null; currency: string; items: OrderItem[] };
type MyReview = { id: string; product_id: string; status: "pending" | "approved" | "rejected" };
const REVIEW_LABEL: Record<MyReview["status"], string> = {
pending: "Reseña enviada · en revisión",
approved: "Reseña publicada",
rejected: "Reseña no publicada",
};
// "Mis pedidos": historial real del cliente (Store API) y, por cada
// producto comprado, el boton para calificarlo. Solo desde aca se puede
// reseñar: el backend exige que el pedido sea del cliente y contenga el
// producto ("Compra verificada").
export function OrdersClient() {
const { token, customer, hasHydrated } = useCustomerStore();
const [orders, setOrders] = useState<Order[] | null>(null);
const [reviews, setReviews] = useState<MyReview[]>([]);
const [error, setError] = useState<string | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const [reviewing, setReviewing] = useState<{ order: Order; item: OrderItem } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setError(null);
try {
const headers = { Authorization: `Bearer ${token}` };
const [ordersRes, reviewsRes] = await Promise.all([fetch("/api/customer/orders", { headers }), fetch("/api/reviews/me", { headers })]);
const ordersData = await ordersRes.json();
if (!ordersRes.ok) throw new Error(ordersData.error ?? "No pudimos cargar tus pedidos");
const reviewsData = await reviewsRes.json().catch(() => ({ reviews: [] }));
setOrders(ordersData.orders);
setReviews(reviewsRes.ok ? reviewsData.reviews : []);
} catch (loadError) {
setError((loadError as Error).message);
setOrders([]);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
if (!hasHydrated) return <Shell><p className="text-slate-500">Cargando…</p></Shell>;
if (!customer || !token) {
return (
<Shell>
<div className="rounded-2xl border border-slate-200 p-8 text-center">
<p className="text-lg font-black text-ari-marino">Inicia sesión para ver tus pedidos</p>
<p className="mt-2 text-sm text-slate-500">Desde aquí puedes calificar los productos que compraste.</p>
<Button variant="primary" className="mt-5" onClick={() => setAccountOpen(true)}>Iniciar sesión</Button>
</div>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</Shell>
);
}
const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId);
return (
<Shell>
{error && <p className="mb-4 rounded-2xl bg-rose-50 p-4 text-sm font-bold text-rose-700">{error}</p>}
{orders === null ? (
<p className="text-slate-500">Cargando tus pedidos…</p>
) : orders.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
<p className="font-black text-ari-marino">Todavía no tienes pedidos</p>
<Button asChild variant="primary" className="mt-4"><Link href="/catalog">Ver catálogo</Link></Button>
</div>
) : (
<ul className="grid gap-5">
{orders.map((order) => (
<li key={order.id} className="rounded-2xl border border-slate-200 p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3 text-sm">
<span className="font-black text-ari-marino">Pedido #{order.displayId ?? order.id.slice(-6)}</span>
<span className="text-slate-500">
{new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}
{order.total !== null && <> · {formatMoney(order.total, order.currency)}</>}
{order.status === "canceled" && <span className="ml-2 font-bold text-rose-600">Cancelado</span>}
</span>
</div>
<ul className="mt-3 grid gap-3">
{order.items.map((item) => {
const review = reviewFor(item.productId);
return (
<li key={`${order.id}-${item.productId}`} className="flex flex-wrap items-center gap-4">
<div className="relative size-16 shrink-0 overflow-hidden rounded-xl bg-slate-50">
{item.thumbnail && <Image src={item.thumbnail} alt="" fill sizes="64px" className="object-contain" unoptimized />}
</div>
<div className="min-w-0 flex-1">
{item.handle ? (
<Link href={`/product/${item.handle}`} className="line-clamp-2 font-bold text-slate-900 hover:text-ari-coral">{item.title}</Link>
) : (
<span className="line-clamp-2 font-bold text-slate-900">{item.title}</span>
)}
<span className="text-xs text-slate-500">Cantidad: {item.quantity}</span>
</div>
{review ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-bold ${review.status === "approved" ? "text-emerald-700" : review.status === "pending" ? "text-amber-700" : "text-slate-500"}`}>
{review.status === "pending" ? <Clock className="size-3.5" /> : <PackageCheck className="size-3.5" />}
{REVIEW_LABEL[review.status]}
</span>
) : order.status === "canceled" ? null : (
<Button size="sm" variant="primary" onClick={() => setReviewing({ order, item })}>
<Star className="size-4" />Calificar
</Button>
)}
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
<ReviewDialog
target={reviewing}
token={token}
onClose={() => setReviewing(null)}
onSubmitted={() => {
setReviewing(null);
load();
}}
/>
</Shell>
);
}
function Shell({ children }: { children: ReactNode }) {
return (
<div className="mx-auto max-w-3xl px-4 py-10">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Mi cuenta</span>
<h1 className="font-display mt-2 text-4xl font-bold tracking-tight text-ari-marino">Mis pedidos</h1>
<div className="mt-8">{children}</div>
</div>
);
}
function ReviewDialog({
target,
token,
onClose,
onSubmitted,
}: {
target: { order: Order; item: OrderItem } | null;
token: string;
onClose: () => void;
onSubmitted: () => void;
}) {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!target) return;
setRating(0);
setHover(0);
setTitle("");
setContent("");
setError(null);
}, [target]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!target) return;
if (rating < 1) return setError("Elige de 1 a 5 estrellas");
if (content.trim().length < 10) return setError("Cuéntanos un poco más (mínimo 10 caracteres)");
setSending(true);
setError(null);
try {
const response = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ product_id: target.item.productId, order_id: target.order.id, rating, title: title.trim() || undefined, content: content.trim() }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error ?? "No pudimos enviar tu reseña");
onSubmitted();
} catch (submitError) {
setError((submitError as Error).message);
} finally {
setSending(false);
}
}
const shown = hover || rating;
return (
<Dialog open={target !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg p-6 sm:p-8">
<DialogPrimitive.Title className="pr-10 text-2xl font-black text-ari-marino">Califica tu compra</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1 line-clamp-2 text-sm text-slate-500">{target?.item.title}</DialogPrimitive.Description>
<form onSubmit={submit} className="mt-6 grid gap-4">
<fieldset>
<legend className="mb-2 text-sm font-black">Tu calificación</legend>
{/* Patron ARIA de radiogroup (no toggles sueltos): el lector de
pantalla anuncia "3 estrellas, seleccionado, 3 de 5", un solo
tab-stop y flechas para cambiar -- con aria-pressed por
estrella solo se anunciaba la estrella exacta, no la
calificacion (reportado por frontend-critic). */}
<div
role="radiogroup"
aria-label="Tu calificación"
className="flex gap-1"
onMouseLeave={() => setHover(0)}
onKeyDown={(event) => {
const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 0;
if (!delta) return;
event.preventDefault();
// Como un radiogroup nativo: avanza desde la opcion con foco
// (si aun no hay calificacion elegida) o desde la elegida.
const buttons = [...event.currentTarget.querySelectorAll("button")];
const focused = buttons.indexOf(document.activeElement as HTMLButtonElement) + 1;
const next = Math.min(5, Math.max(1, (rating || focused || 0) + delta));
setRating(next);
setError(null);
(event.currentTarget.querySelectorAll("button")[next - 1] as HTMLButtonElement | undefined)?.focus();
}}
>
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
role="radio"
aria-checked={rating === value}
aria-label={`${value} ${value === 1 ? "estrella" : "estrellas"}`}
tabIndex={rating ? (rating === value ? 0 : -1) : value === 1 ? 0 : -1}
onMouseEnter={() => setHover(value)}
onClick={() => { setRating(value); setError(null); }}
className="rounded-md p-0.5 transition hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ari-marino"
>
<Star className={`size-9 ${value <= shown ? "fill-amber-500 text-amber-600" : "text-slate-400"}`} />
</button>
))}
</div>
</fieldset>
<label className="grid gap-1.5 text-sm font-black">
Título (opcional)
<Input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={80} placeholder="Ej: ¡A mi hijo le encantó!" />
</label>
<label className="grid gap-1.5 text-sm font-black">
Tu reseña
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={5}
maxLength={2000}
placeholder="¿Qué te pareció el producto? ¿Llegó bien?"
className="rounded-2xl border border-slate-200 bg-white p-4 text-sm font-normal text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15"
/>
</label>
<p className="text-xs text-slate-500">Tu reseña se publica después de que la revisemos. Mostramos tu nombre y la inicial de tu apellido.</p>
{error && <p className="rounded-xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}
<Button type="submit" variant="primary" size="lg" disabled={sending}>{sending ? "Enviando…" : "Enviar reseña"}</Button>
</form>
</DialogContent>
</Dialog>
);
}
@@ -8,6 +8,7 @@ import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { Category, FacetDistribution, Product } from "@/lib/headless/types"; import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy";
// Cada cuantas tarjetas de producto se intercala un tile promocional en // Cada cuantas tarjetas de producto se intercala un tile promocional en
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles // la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
@@ -23,12 +24,19 @@ type CatalogClientProps = {
initialCategory?: string; initialCategory?: string;
initialCollection?: string; initialCollection?: string;
initialQuery?: string; initialQuery?: string;
initialTaxonomy?: TaxonomyFilter;
}; };
// Sin guiones ni espacios: "spiderman" encuentra "Spider-Man".
function normalizeSearch(value: string): string {
return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, "");
}
function matchesQuery(product: Product, query: string): boolean { function matchesQuery(product: Product, query: string): boolean {
if (!query) return true; if (!query) return true;
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase(); const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
return search.includes(query.toLowerCase()); const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" "));
return search.includes(normalizeSearch(query));
} }
function matchesCategory(product: Product, allowedCategories: string[]): boolean { function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category); return allowedCategories.includes(product.category);
@@ -69,8 +77,18 @@ export function CatalogClient({
initialCategory = "Todos", initialCategory = "Todos",
initialCollection = "", initialCollection = "",
initialQuery = "", initialQuery = "",
initialTaxonomy = {},
}: CatalogClientProps) { }: CatalogClientProps) {
const [query, setQuery] = useState(initialQuery); const [query, setQuery] = useState(initialQuery);
// Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side
// sobre los tags -- Meilisearch no tiene estos campos como facetas.
const [taxonomy, setTaxonomy] = useState<TaxonomyFilter>(initialTaxonomy);
const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje);
// El titulo solo refleja la franquicia/saga/personaje que viene en la URL
// (menu "Personajes"), igual que la barra de navegacion: si reaccionara
// al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con
// "Todo" marcado activo arriba (reportado por frontend-critic).
const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || "";
const [category, setCategory] = useState(initialCategory); const [category, setCategory] = useState(initialCategory);
const [collection, setCollection] = useState(initialCollection); const [collection, setCollection] = useState(initialCollection);
const [brand, setBrand] = useState("Todas"); const [brand, setBrand] = useState("Todas");
@@ -96,13 +114,30 @@ export function CatalogClient({
setAge("Todas"); setAge("Todas");
setSort("featured"); setSort("featured");
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]); setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje });
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]);
// Valores de franquicia/personaje presentes en el catalogo. Los
// personajes se acotan a la franquicia elegida (si hay una), para no
// ofrecer "Spider-Man" mientras se mira Disney.
const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]);
const characters = useMemo(
() => [
"Todos",
...new Set(
products
.filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }))
.flatMap((product) => taxonomyValues(product, "personaje")),
),
],
[products, taxonomy.franquicia, taxonomy.saga],
);
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se // Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto // expande a la lista de nombres reales de sus hijos -- ningun producto
// tiene la raiz asignada directamente. Si no es una raiz conocida, se // tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre. // trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category]; const allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]);
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]); const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo. // No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => { const promoRoots = useMemo(() => {
@@ -120,7 +155,8 @@ export function CatalogClient({
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesBrand(product, brand) && matchesBrand(product, brand) &&
matchesAge(product, age) && matchesAge(product, age) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
) )
.sort((a, b) => .sort((a, b) =>
sort === "name" sort === "name"
@@ -129,7 +165,7 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew) ? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured), : Number(b.featured) - Number(a.featured),
), ),
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort], [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy],
); );
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos, // Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
@@ -161,7 +197,7 @@ export function CatalogClient({
// la busqueda en vivo todavia no respondio. // la busqueda en vivo todavia no respondio.
const fallbackCategoryCounts = useMemo(() => { const fallbackCategoryCounts = useMemo(() => {
const base = products.filter( const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice), (product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todos: base.length }; const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) { for (const name of categories) {
@@ -170,7 +206,7 @@ export function CatalogClient({
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length; counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
} }
return counts; return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]); }, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
const fallbackBrandCounts = useMemo(() => { const fallbackBrandCounts = useMemo(() => {
const base = products.filter( const base = products.filter(
@@ -179,7 +215,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) && (category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesAge(product, age) && matchesAge(product, age) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todas: base.length }; const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) { for (const value of brands) {
@@ -187,7 +224,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.brand === value).length; counts[value] = base.filter((product) => product.brand === value).length;
} }
return counts; return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]); }, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
const fallbackAgeCounts = useMemo(() => { const fallbackAgeCounts = useMemo(() => {
const base = products.filter( const base = products.filter(
@@ -196,7 +233,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) && (category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesBrand(product, brand) && matchesBrand(product, brand) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todas: base.length }; const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) { for (const value of ages) {
@@ -204,7 +242,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.ageRange === value).length; counts[value] = base.filter((product) => product.ageRange === value).length;
} }
return counts; return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]); }, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]);
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe // "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
// como valor indexado en Meilisearch (ningun producto lo tiene como // como valor indexado en Meilisearch (ningun producto lo tiene como
@@ -296,18 +334,33 @@ export function CatalogClient({
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]); }, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
const categoryCounts = useMemo( const categoryCounts = useMemo(
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), () => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal], [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
); );
const brandCounts = useMemo( const brandCounts = useMemo(
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), () => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal], [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
); );
const ageCounts = useMemo( const ageCounts = useMemo(
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), () => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal], [fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive],
); );
// Conteos de franquicia/personaje: siempre client-side (no son facetas
// de Meilisearch), excluyendo su propia dimension como el resto.
const franchiseCounts = useMemo(() => {
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje }));
const counts: Record<string, number> = { Todas: base.length };
for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length;
return counts;
}, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]);
const characterCounts = useMemo(() => {
const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga }));
const counts: Record<string, number> = { Todos: base.length };
for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length;
return counts;
}, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]);
const reset = () => { const reset = () => {
setQuery(""); setQuery("");
setCategory("Todos"); setCategory("Todos");
@@ -316,6 +369,7 @@ export function CatalogClient({
setAge("Todas"); setAge("Todas");
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
setSort("featured"); setSort("featured");
setTaxonomy({});
}; };
return ( return (
@@ -343,9 +397,9 @@ export function CatalogClient({
</nav> </nav>
)} )}
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral"> <span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
Catálogo completo {urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
</span> </span>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1> <h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
<p className="mt-3 text-slate-500"> <p className="mt-3 text-slate-500">
Filtra por categoría, marca o edad sin recargar la página. Filtra por categoría, marca o edad sin recargar la página.
</p> </p>
@@ -381,6 +435,33 @@ export function CatalogClient({
onChange={setCategory} onChange={setCategory}
counts={categoryCounts} counts={categoryCounts}
/> />
{franchises.length > 1 && (
<Filter
title="Franquicia"
values={franchises}
selected={taxonomy.franquicia || "Todas"}
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
counts={franchiseCounts}
/>
)}
{taxonomy.saga && (
<div>
<p className="mb-3 text-sm font-black">Saga</p>
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
{taxonomy.saga}
<X className="size-3" aria-label="Quitar saga" />
</button>
</div>
)}
{characters.length > 1 && (
<Filter
title="Personaje"
values={characters}
selected={taxonomy.personaje || "Todos"}
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
counts={characterCounts}
/>
)}
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} /> <Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} /> <Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
@@ -3,12 +3,13 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react"; import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
import { type FormEvent, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia"; import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { formatMoney } from "@/lib/utils"; import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useLocationStore } from "@/store/location-store";
const SELECT_CLASS = 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"; "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";
@@ -72,12 +73,24 @@ export function CheckoutClient() {
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC"); const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState(""); const [docNumber, setDocNumber] = useState("");
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
const savedDepartmentId = useLocationStore((state) => state.departmentId);
const savedCityId = useLocationStore((state) => state.cityId);
const locationHydrated = useLocationStore((state) => state.hasHydrated);
const saveLocation = useLocationStore((state) => state.setLocation);
useEffect(() => {
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
}, [locationHydrated, savedDepartmentId, savedCityId]);
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0); const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const currency = items[0]?.product.currency; const currency = items[0]?.product.currency;
async function submitAddress(event: FormEvent) { async function submitAddress(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (!cartId || !selectedCiudad || !selectedDepartamento) return; if (!cartId || !selectedCiudad || !selectedDepartamento) return;
saveLocation(selectedDepartamento.id, selectedCiudad.id);
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -0,0 +1,100 @@
"use client";
import { MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { useLocationStore } from "@/store/location-store";
const selectClass =
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
export function LocationPicker({ firstName }: { firstName: string | null }) {
const departmentId = useLocationStore((state) => state.departmentId);
const cityId = useLocationStore((state) => state.cityId);
const hydrated = useLocationStore((state) => state.hasHydrated);
const setLocation = useLocationStore((state) => state.setLocation);
const [open, setOpen] = useState(false);
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
const [draftCity, setDraftCity] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
useEffect(() => {
if (!open) return;
setDraftDepartment(departmentId);
setDraftCity(cityId);
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, departmentId, cityId]);
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
>
<MapPin className="size-5 shrink-0 text-ari-coral" />
<span className="leading-tight">
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
<div className="mt-4 grid gap-3">
<select
aria-label="Departamento"
className={selectClass}
value={draftDepartment ?? ""}
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
>
<option value="">Departamento</option>
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select
aria-label="Ciudad"
className={selectClass}
value={draftCity ?? ""}
disabled={!draftDepartment}
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
>
<option value="">Ciudad o municipio</option>
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Button
variant="primary"
disabled={!draftDepartment || !draftCity}
onClick={() => {
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
setOpen(false);
}}
>
Guardar ciudad
</Button>
</div>
</div>
)}
</div>
);
}
+355 -33
View File
@@ -2,13 +2,15 @@
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, ShoppingCart, UserRound } from "lucide-react"; import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AccountDialog } from "@/components/account/account-dialog"; import { AccountDialog } from "@/components/account/account-dialog";
import { LocationPicker } from "@/components/layout/location-picker";
import { PredictiveSearch } from "@/components/search/predictive-search"; import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types"; import type { Category, Product } from "@/lib/headless/types";
import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useCustomerStore } from "@/store/customer-store"; import { useCustomerStore } from "@/store/customer-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
@@ -42,14 +44,34 @@ export function Navbar({ products, categories }: { products: Product[]; categori
const tree = useMemo(() => buildCategoryTree(categories), [categories]); const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones): // Derivado de los productos reales (no de un endpoint de colecciones):
// solo aparecen colecciones que de verdad tienen productos hoy. // solo aparecen colecciones que de verdad tienen productos hoy.
// Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el
// provider no trae tags (mock/shopify), "Personajes" cae a la lista plana
// de colecciones de siempre.
const franchises = useMemo(() => buildFranchiseTree(products), [products]);
const collections = useMemo( const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))], () => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products], [products],
); );
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
// se expande recien por debajo de 40px) porque achicar un header sticky
// cambia la altura del documento -- con un solo umbral el header
// parpadea entre los dos estados justo en el borde.
const [compact, setCompact] = useState(false);
useEffect(() => {
const onScroll = () => {
const y = window.scrollY;
setCompact((current) => (current ? y > 40 : y > 160));
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return ( return (
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl"> <header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<div className="bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm"> <div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span> Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
</div> </div>
@@ -61,16 +83,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
angosto/recortado). En desktop (lg+) se mantiene la fila unica angosto/recortado). En desktop (lg+) se mantiene la fila unica
original -- ahi sobra espacio y partirla en dos solo alargaria original -- ahi sobra espacio y partirla en dos solo alargaria
el header sin necesidad. */} el header sin necesidad. */}
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4"> <div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8"> <div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
{/* Isotipo circular con icono + wordmark integrados (como en la {/* Isotipo circular con icono + wordmark integrados (como en la
version previa a ADR 0008), pedido explicito del usuario en version previa a ADR 0008), pedido explicito del usuario en
vez del lockup rectangular. logo-ari.svg (rectangular) se vez del lockup rectangular. logo-ari.svg (rectangular) se
mantiene para el footer. */} mantiene para el footer. */}
<Link href="/" aria-label="ARI shopping" className="shrink-0"> <Link href="/" aria-label="ARI shopping" className="shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */} {/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className="size-16 lg:size-20" width={200} height={200} /> <img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
</Link> </Link>
<div className="hidden lg:block">
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
</div>
<div className="hidden lg:block"> <div className="hidden lg:block">
<PredictiveSearch /> <PredictiveSearch />
</div> </div>
@@ -84,6 +109,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<UserRound /> <UserRound />
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />} {isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
</Button> </Button>
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
Cristian / Cuenta y listas" de Amazon): en mobile no hay
lugar y los iconos solos ya se entienden. */}
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
</button>
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
<span className="block text-xs text-slate-500">Cambios y</span>
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
</Link>
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}> <Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
<Heart /> <Heart />
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>} {wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
@@ -100,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</div> </div>
</div> </div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}> <Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} /> <DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
</Suspense> </Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -117,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} /> <MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense> </Suspense>
</div> </div>
{collections.length > 0 && ( {franchises.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
<div className="grid gap-4">
{franchises.map((franchise) => (
<div key={franchise.name}>
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
</Link>
<div className="mt-2 flex flex-wrap gap-2">
{franchise.sagas.map((saga) => (
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">
{saga.name}
</Link>
))}
</div>
</div>
))}
</div>
</>
)}
{franchises.length === 0 && collections.length > 0 && (
<> <>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p> <p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -142,16 +199,32 @@ function useCategoryNavigationState() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? ""; const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" };
const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje);
const isCatalog = pathname === "/catalog"; const isCatalog = pathname === "/catalog";
return { return {
isAllActive: isCatalog && !currentCategory, isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
isActive: (name: string) => isCatalog && currentCategory === name, isActive: (name: string) => isCatalog && currentCategory === name,
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name,
anyTaxonomyActive: isCatalog && anyTaxonomy,
}; };
} }
function pillClass(active: boolean): string { type NavigationState = ReturnType<typeof useCategoryNavigationState>;
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
const INACTIVE_NAVIGATION: NavigationState = {
isAllActive: false,
isActive: () => false,
isCollectionActive: () => false,
isTaxonomyActive: () => false,
anyTaxonomyActive: false,
};
function pillClass(active: boolean, dense = false): string {
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
active active
? "bg-white text-ari-marino shadow-lg shadow-black/10" ? "bg-white text-ari-marino shadow-lg shadow-black/10"
: "text-white/90 hover:bg-white/10 hover:text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
@@ -166,26 +239,26 @@ function mobileLinkClass(active: boolean): string {
}`; }`;
} }
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) { type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />; function DesktopCategoryNavigation(props: NavBarProps) {
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
} }
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) { function DesktopCategoryNavigationFallback(props: NavBarProps) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />; return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
} }
function CategoryNavBar({ // Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
tree, // (cada una con su desplegable de subcategorias) -- antes solo se veian
onOpenMenu, // "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
isActive, // 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
isAllActive, // "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
}: { // La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
tree: CategoryNode[]; function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
onOpenMenu: () => void; const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
isActive: (name: string) => boolean; const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
isAllActive: boolean;
}) {
return ( return (
<nav className="bg-ari-marino text-white"> <nav className="bg-ari-marino text-white">
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2"> <div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
@@ -194,24 +267,265 @@ function CategoryNavBar({
size="icon" size="icon"
onClick={onOpenMenu} onClick={onOpenMenu}
aria-label="Abrir menú principal" aria-label="Abrir menú principal"
className="mr-2 shrink-0 text-white hover:bg-white/10" className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
> >
<Menu /> <Menu />
</Button> </Button>
<div className="shrink-0 xl:hidden">
<DepartmentsMegaMenu tree={tree} isActive={isActive} /> <DepartmentsMegaMenu tree={tree} isActive={isActive} />
</div>
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}> <Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo Todo
</Link> </Link>
<div className="hidden items-center gap-0.5 xl:flex">
{tree.map((root) => {
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
if (root.children.length === 0) {
return (
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
{root.name}
</Link>
);
}
return (
<NavDropdown key={root.id} label={root.name} active={active}>
{(close) => (
<ul className="grid min-w-56 gap-1">
{root.children.map((child) => (
<li key={child.id}>
<Link
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={close}
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
<li className="mt-1 border-t border-white/10 pt-2">
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
Ver todo en {root.name}
</Link>
</li>
</ul>
)}
</NavDropdown>
);
})}
</div>
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
{(franchises.length > 0 || collections.length > 0) && (
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
{(close) => franchises.length > 0 ? (
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
) : (
<div className="grid w-[28rem] grid-cols-2 gap-1.5">
{collections.map((collection) => (
<Link
key={collection}
href={`/catalog?collection=${encodeURIComponent(collection)}`}
prefetch={false}
aria-current={isCollectionActive(collection) ? "page" : undefined}
onClick={close}
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
>
{collection}
</Link>
))}
</div>
)}
</NavDropdown>
)}
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
de ayuda que la tienda ya tiene (antes solo en el footer). */}
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
{(close) => (
<ul className="grid min-w-60 gap-1">
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
<li className="mt-1 border-t border-white/10 pt-2">
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
</li>
</ul>
)}
</NavDropdown>
</div>
</div> </div>
</nav> </nav>
); );
} }
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
// alternara, cerraria el menu que se acaba de abrir por hover y para el
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
// touch (sin hover) el click sigue alternando normal.
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
// solo en tablets (iPad horizontal cae en el rango del mega menu).
// Reportado por frontend-critic con log de eventos del navegador.
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
if (open && Date.now() - hoverOpenedAt < 600) return true;
return !open;
}
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
// Earth): una columna por franquicia con su total, sus sagas y los
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
// "Spider-Man" solo lo de Spider-Man.
function FranchisePanel({
franchises,
isTaxonomyActive,
close,
}: {
franchises: FranchiseNode[];
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
close: () => void;
}) {
const link = (kind: TaxonomyKind, name: string, base: string) => (
<Link
href={taxonomyHref(kind, name)}
prefetch={false}
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
onClick={close}
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
>
{name}
</Link>
);
return (
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
{franchises.map((franchise) => (
<div key={franchise.name}>
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
</p>
<ul className="mt-2.5 grid gap-2.5">
{franchise.sagas.map((saga) => (
<li key={saga.name}>
{link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")}
{saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? (
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
{saga.characters.map((character) => (
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
))}
</div>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
);
}
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
function NavDropdown({
label,
labelClassName,
ariaLabel,
active,
align = "left",
icon,
children,
}: {
label: string;
labelClassName?: string;
ariaLabel?: string;
active: boolean;
align?: "left" | "right";
icon?: ReactNode;
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimer.current = setTimeout(() => setOpen(false), 200);
};
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
useEffect(() => () => cancelClose(), []);
return (
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
>
{icon}
<span className={labelClassName}>{label}</span>
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
</div>
)}
</div>
);
}
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) { 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 hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => { const cancelClose = () => {
@@ -237,7 +551,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false); if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false); if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
} }
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
@@ -251,14 +571,16 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
const openNow = () => { const openNow = () => {
cancelClose(); cancelClose();
if (!open) hoverOpenedAt.current = Date.now();
setOpen(true); setOpen(true);
}; };
return ( return (
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}> <div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" aria-haspopup="true"
aria-label="Departamentos" aria-label="Departamentos"
@@ -0,0 +1,22 @@
"use client";
import { useEffect } from "react";
import { StarRating } from "@/components/product/star-rating";
import { useReviewSummaryStore } from "@/store/review-summary-store";
// Promedio + cantidad de reseñas aprobadas. No renderiza nada si el
// producto aun no tiene reseñas (ni un link vacio, en la ficha).
export function CardRating({ productId, href }: { productId: string; href?: string }) {
const load = useReviewSummaryStore((state) => state.load);
const entry = useReviewSummaryStore((state) => state.summary[productId]);
useEffect(() => load(), [load]);
if (!entry || entry.count === 0) return null;
const content = (
<>
<StarRating value={entry.average} />
{entry.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })} ({entry.count} {entry.count === 1 ? "reseña" : "reseñas"})
</>
);
const className = "flex items-center gap-1.5 text-xs font-bold text-slate-500";
return href ? <a href={href} className={`${className} mt-3 hover:text-ari-coral`}>{content}</a> : <span className={className}>{content}</span>;
}
@@ -0,0 +1,126 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
// Galería deslizable de la tarjeta de producto: permite ver todas las
// imágenes desde el listado sin entrar al producto. En móvil se desliza
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
export function ProductCardGallery({
images,
name,
href,
priority = false,
}: {
images: string[];
name: string;
href: string;
priority?: boolean;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
const total = images.length;
const goTo = (target: number) => {
const node = scroller.current;
if (!node) return;
const next = Math.max(0, Math.min(total - 1, target));
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
};
const onScroll = () => {
const node = scroller.current;
if (!node || node.clientWidth === 0) return;
setIndex(Math.round(node.scrollLeft / node.clientWidth));
};
return (
<>
<div
ref={scroller}
onScroll={total > 1 ? onScroll : undefined}
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((src, slide) => (
<Link
key={src}
href={href}
prefetch={false}
className="relative block h-full w-full shrink-0 snap-center snap-always"
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
tabIndex={slide === 0 ? 0 : -1}
>
{/* object-contain (no object-cover): las fotos secundarias del
catálogo no son cuadradas (verticales tipo 933x1500 u
horizontales) y cover les cortaba cabeza, pies o bordes
-- reportado en vivo con Tristeza y Buzz. Mismo criterio que
product-gallery.tsx; el letterbox queda del bg-white de la
tarjeta. */}
{/* unoptimized desde la 2da imagen: pasar todas por el
optimizador de Next multiplica el trabajo de sharp dentro
del pod (~58 imagenes en el catalogo vs. 18 antes) -- una
carga en rafaga de todas las galerias dejo un pod de prod
OOMKilled (limite 768Mi) el 2026-09-27. Los originales de
media.cruzcloud.net pesan ~165 KB promedio y son lazy: solo
se bajan al deslizar. La 1ra sigue optimizada como antes. */}
<Image
src={src}
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
fill
unoptimized={slide > 0}
priority={priority && slide === 0}
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
className="object-contain transition duration-500 group-hover:scale-[1.035]"
/>
</Link>
))}
</div>
{total > 1 && (
<>
<button
type="button"
aria-label="Imagen anterior"
disabled={index === 0}
onClick={() => goTo(index - 1)}
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Imagen siguiente"
disabled={index === total - 1}
onClick={() => goTo(index + 1)}
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
>
<ChevronRight className="size-5" />
</button>
<div className="absolute inset-x-0 bottom-2 flex justify-center">
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
{images.map((src, dot) => (
<button
key={src}
type="button"
aria-label={`Ver imagen ${dot + 1} de ${total}`}
aria-current={dot === index}
onClick={() => goTo(dot)}
className="grid h-5 place-items-center px-[3px]"
>
<span
className={`block h-1.5 rounded-full transition-all ${
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
}`}
/>
</button>
))}
</div>
</div>
</>
)}
</>
);
}
@@ -1,9 +1,10 @@
"use client"; "use client";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react"; import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { CardRating } from "@/components/product/card-rating";
import { ProductCardGallery } from "@/components/product/product-card-gallery";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
@@ -22,6 +23,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p
? Math.round((1 - product.price / product.compareAtPrice) * 100) ? Math.round((1 - product.price / product.compareAtPrice) * 100)
: null; : null;
const galleryImages = Array.from(new Set(product.images.filter(Boolean)));
if (galleryImages.length === 0) galleryImages.push(product.thumbnail);
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id);
const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0]; const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0];
const outOfStock = selectedVariant?.available === false; const outOfStock = selectedVariant?.available === false;
@@ -37,7 +41,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
return ( return (
<article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10"> <article className="group flex h-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white shadow-lg shadow-ari-marino/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-ari-coral/10">
<div className="relative aspect-square overflow-hidden bg-white"> <div className="relative aspect-square overflow-hidden bg-white">
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link> <ProductCardGallery images={galleryImages} name={product.name} href={`/product/${product.slug}`} priority={priority} />
<div className="absolute left-3 top-3 flex gap-2">{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> <div className="absolute left-3 top-3 flex gap-2">{outOfStock && <Badge className="bg-rose-600 text-white">Agotado</Badge>}{discountPercent && discountPercent > 0 && <Badge className="bg-ari-coral-claro text-ari-marino">-{discountPercent}%</Badge>}{product.isNew && <Badge className="bg-ari-marino text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-ari-coral-claro text-ari-marino"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
{outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />} {outOfStock && <div className="pointer-events-none absolute inset-0 bg-white/40" />}
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button> <button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-ari-coral text-white" : "bg-white/95 text-slate-900 hover:text-ari-coral"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
@@ -45,6 +49,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
<div className="flex flex-1 flex-col space-y-3 p-5"> <div className="flex flex-1 flex-col space-y-3 p-5">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div> <div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link> <Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
<CardRating productId={product.id} />
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p> <p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
{product.variants.length > 1 && ( {product.variants.length > 1 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { BadgeCheck, MessageSquareText } from "lucide-react";
import { useEffect, useState } from "react";
import { StarRating } from "@/components/product/star-rating";
type Review = { id: string; author_name: string; rating: number; title: string | null; content: string; created_at: string; verified_purchase: boolean };
// Reseñas de la ficha: solo las aprobadas por el dueño (el backend no
// devuelve las pendientes), siempre de compradores verificados.
export function ProductReviews({ productId }: { productId: string }) {
const [data, setData] = useState<{ reviews: Review[]; average: number; count: number } | null>(null);
useEffect(() => {
fetch(`/api/reviews?product_id=${encodeURIComponent(productId)}`)
.then((response) => response.json())
.then(setData)
.catch(() => setData({ reviews: [], average: 0, count: 0 }));
}, [productId]);
return (
<section id="resenas" aria-labelledby="resenas-titulo" className="mt-10 scroll-mt-40">
<h2 id="resenas-titulo" className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-slate-500">
<MessageSquareText className="size-4" />
Reseñas de clientes
</h2>
{!data ? (
<p className="mt-4 text-sm text-slate-400">Cargando reseñas…</p>
) : data.count === 0 ? (
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 p-5 text-sm text-slate-600">
Aún no hay reseñas de este producto. ¿Ya lo compraste?{" "}
<Link href="/cuenta/pedidos" className="font-bold text-ari-marino underline decoration-ari-coral-claro underline-offset-2">Califícalo desde Mis pedidos</Link>.
</div>
) : (
<>
<div className="mt-4 flex items-center gap-3">
<StarRating value={data.average} size="lg" />
<span className="text-2xl font-black text-ari-marino">{data.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })}</span>
<span className="text-sm text-slate-500">de 5 · {data.count} {data.count === 1 ? "reseña" : "reseñas"}</span>
</div>
<ul className="mt-6 grid gap-4">
{data.reviews.map((review) => (
<li key={review.id} className="rounded-2xl border border-slate-200 p-5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<StarRating value={review.rating} />
{review.title && <strong className="text-slate-900">{review.title}</strong>}
</div>
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700">{review.content}</p>
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span className="font-bold text-slate-700">{review.author_name}</span>
<span>{new Date(review.created_at).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}</span>
{review.verified_purchase && (
<span className="inline-flex items-center gap-1 font-bold text-emerald-700"><BadgeCheck className="size-3.5" />Compra verificada</span>
)}
</p>
</li>
))}
</ul>
</>
)}
</section>
);
}
@@ -0,0 +1,18 @@
import { Star } from "lucide-react";
// Estrellas de solo lectura. El relleno parcial (ej. 4.5) se dibuja
// recortando una capa de estrellas llenas encima de las vacias. amber-600
// (no 400): contraste ~3.2:1 sobre blanco, minimo WCAG 1.4.11 para un
// grafico informativo (amber-400 daba 1.7:1, reportado por frontend-critic).
export function StarRating({ value, size = "sm", className = "" }: { value: number; size?: "sm" | "md" | "lg"; className?: string }) {
const px = size === "lg" ? "size-6" : size === "md" ? "size-5" : "size-4";
const pct = Math.max(0, Math.min(100, (value / 5) * 100));
return (
<span className={`relative inline-flex ${className}`} role="img" aria-label={`${value.toLocaleString("es-CO")} de 5 estrellas`}>
<span className="flex text-slate-300">{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} fill-current`} />)}</span>
<span className="absolute inset-0 flex overflow-hidden text-amber-600" style={{ width: `${pct}%` }}>
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} shrink-0 fill-current`} />)}
</span>
</span>
);
}
@@ -25,14 +25,14 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: app - name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.275 image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.293
env: env:
# La actualiza el mismo paso del CD (.gitea/workflows/build.yaml) # La actualiza el mismo paso del CD (.gitea/workflows/build.yaml)
# que bumpea el tag de la imagen -- el footer la lee # que bumpea el tag de la imagen -- el footer la lee
# (process.env.APP_VERSION) para mostrar la versión real # (process.env.APP_VERSION) para mostrar la versión real
# desplegada en vez de un "v4.1" fijo. # desplegada en vez de un "v4.1" fijo.
- name: APP_VERSION - name: APP_VERSION
value: v1.0.275 value: v1.0.293
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa
- name: MEDUSA_BACKEND_URL - name: MEDUSA_BACKEND_URL
@@ -0,0 +1,111 @@
// Reseñas de productos + pedidos del cliente (Store API de Medusa, modulo
// propio commerce-backend/src/modules/product-review). Server-side y sin
// cache: las reseñas cambian al aprobarse en el admin y los pedidos son
// estado de sesion.
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) throw new Error("MEDUSA_BACKEND_URL no está configurado.");
return value.replace(/\/$/, "");
}
async function medusaFetch<T>(path: string, init?: { method?: string; body?: unknown; token?: string }): Promise<T> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
throw Object.assign(new Error(message), { status: response.status });
}
return data as T;
}
export interface PublicReview {
id: string;
author_name: string;
rating: number;
title: string | null;
content: string;
created_at: string;
verified_purchase: boolean;
}
export interface ReviewSummary {
average: number;
count: number;
}
export interface MyReview {
id: string;
product_id: string;
order_id: string;
rating: number;
status: "pending" | "approved" | "rejected";
}
export interface CustomerOrder {
id: string;
displayId: number | null;
createdAt: string;
status: string;
total: number | null;
currency: string;
items: { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number }[];
}
export function getProductReviews(productId: string) {
return medusaFetch<{ reviews: PublicReview[]; average: number; count: number }>(`/store/reviews?product_id=${encodeURIComponent(productId)}`);
}
export async function getReviewSummary(): Promise<Record<string, ReviewSummary>> {
return (await medusaFetch<{ summary: Record<string, ReviewSummary> }>("/store/reviews/summary")).summary;
}
export async function getMyReviews(token: string): Promise<MyReview[]> {
return (await medusaFetch<{ reviews: MyReview[] }>("/store/reviews/me", { token })).reviews;
}
export function createReview(token: string, body: { product_id: string; order_id: string; rating: number; title?: string; content: string }) {
return medusaFetch<{ review: { id: string; status: string } }>("/store/reviews", { method: "POST", body, token });
}
type MedusaOrder = {
id: string;
display_id?: number | null;
created_at: string;
status: string;
total?: number | null;
currency_code?: string | null;
items?: { product_id?: string | null; product_title?: string | null; title?: string | null; thumbnail?: string | null; product_handle?: string | null; quantity?: number }[];
};
export async function getMyOrders(token: string): Promise<CustomerOrder[]> {
const fields = "id,display_id,created_at,status,total,currency_code,*items";
const data = await medusaFetch<{ orders: MedusaOrder[] }>(`/store/orders?limit=50&order=-created_at&fields=${encodeURIComponent(fields)}`, { token });
return data.orders.map((order) => ({
id: order.id,
displayId: order.display_id ?? null,
createdAt: order.created_at,
status: order.status,
total: typeof order.total === "number" ? order.total : null,
currency: (order.currency_code ?? "cop").toUpperCase(),
items: (order.items ?? [])
.filter((item) => item.product_id)
.map((item) => ({
productId: item.product_id as string,
title: item.product_title || item.title || "Producto",
thumbnail: item.thumbnail ?? null,
handle: item.product_handle ?? null,
quantity: item.quantity ?? 1,
})),
}));
}
+68
View File
@@ -0,0 +1,68 @@
import type { Product } from "@/lib/headless/types";
// Taxonomia Franquicia > Saga > Personaje (ej. Disney > Toy Story > Rex,
// Marvel > Spider-Man > Spider-Man). Vive en los tags de Medusa con
// prefijo ("franquicia:Disney", "saga:Toy Story", "personaje:Rex") porque
// la coleccion de Medusa es una sola por producto y mezclaba niveles
// ("Disney" y "Toy Story" como pares). Los tags son muchos-a-muchos: un
// producto puede tener varios personajes ("Minnie y Mickey").
export type TaxonomyKind = "franquicia" | "saga" | "personaje";
export const TAXONOMY_KINDS: TaxonomyKind[] = ["franquicia", "saga", "personaje"];
export function taxonomyValues(product: Product, kind: TaxonomyKind): string[] {
const prefix = `${kind}:`;
return product.tags.filter((tag) => tag.startsWith(prefix)).map((tag) => tag.slice(prefix.length));
}
export type TaxonomyFilter = Partial<Record<TaxonomyKind, string>>;
export function matchesTaxonomy(product: Product, filter: TaxonomyFilter): boolean {
return TAXONOMY_KINDS.every((kind) => {
const value = filter[kind];
return !value || taxonomyValues(product, kind).includes(value);
});
}
export type SagaNode = { name: string; count: number; characters: { name: string; count: number }[] };
export type FranchiseNode = { name: string; count: number; sagas: SagaNode[] };
function byCountThenName<T extends { name: string; count: number }>(a: T, b: T): number {
return b.count - a.count || a.name.localeCompare(b.name, "es");
}
export function buildFranchiseTree(products: Product[]): FranchiseNode[] {
const franchises = new Map<string, { count: number; sagas: Map<string, { count: number; characters: Map<string, number> }> }>();
for (const product of products) {
const sagas = taxonomyValues(product, "saga");
const characters = taxonomyValues(product, "personaje");
for (const franchise of taxonomyValues(product, "franquicia")) {
const node = franchises.get(franchise) ?? { count: 0, sagas: new Map() };
node.count += 1;
for (const saga of sagas) {
const sagaNode = node.sagas.get(saga) ?? { count: 0, characters: new Map() };
sagaNode.count += 1;
for (const character of characters) sagaNode.characters.set(character, (sagaNode.characters.get(character) ?? 0) + 1);
node.sagas.set(saga, sagaNode);
}
franchises.set(franchise, node);
}
}
return [...franchises.entries()]
.map(([name, node]) => ({
name,
count: node.count,
sagas: [...node.sagas.entries()]
.map(([sagaName, sagaNode]) => ({
name: sagaName,
count: sagaNode.count,
characters: [...sagaNode.characters.entries()].map(([characterName, count]) => ({ name: characterName, count })).sort(byCountThenName),
}))
.sort(byCountThenName),
}))
.sort(byCountThenName);
}
export function taxonomyHref(kind: TaxonomyKind, value: string): string {
return `/catalog?${kind}=${encodeURIComponent(value)}`;
}
@@ -0,0 +1,34 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Ciudad de envio elegida desde el header ("Enviar a ... · Cali"), mismo
// patron persistido que wishlist/cart. Se guardan los ids DIVIPOLA (no
// texto libre, ver lib/data/colombia.ts) para que el checkout arranque con
// el mismo departamento/ciudad ya seleccionados.
interface LocationState {
departmentId: number | null;
cityId: number | null;
hasHydrated: boolean;
setHasHydrated: (value: boolean) => void;
setLocation: (departmentId: number, cityId: number) => void;
}
export const useLocationStore = create<LocationState>()(
persist(
(set) => ({
departmentId: null,
cityId: null,
hasHydrated: false,
setHasHydrated: (value) => set({ hasHydrated: value }),
setLocation: (departmentId, cityId) => set({ departmentId, cityId }),
}),
{
name: "ari-shopping-location-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ departmentId: state.departmentId, cityId: state.cityId }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
},
),
);
@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
// Promedio/cantidad de reseñas aprobadas por producto, para las estrellas
// de las tarjetas. Se pide UNA vez por sesion (/api/reviews/summary) en
// vez de una llamada por tarjeta, y sin depender del cache ISR de las
// paginas (una reseña recien aprobada aparece al recargar).
interface ReviewSummaryState {
summary: Record<string, { average: number; count: number }>;
status: "idle" | "loading" | "ready";
load: () => void;
}
export const useReviewSummaryStore = create<ReviewSummaryState>()((set, get) => ({
summary: {},
status: "idle",
load: () => {
if (get().status !== "idle") return;
set({ status: "loading" });
fetch("/api/reviews/summary")
.then((response) => response.json())
.then((data) => set({ summary: data.summary ?? {}, status: "ready" }))
.catch(() => set({ status: "ready" }));
},
}));