Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce7030c4d | ||
|
|
3b2f99c7a0 | ||
|
|
00d7f58c9a | ||
|
|
6f24109d91 | ||
|
|
edd14ca790 | ||
|
|
b6e3bddbc6 | ||
|
|
3c0ce03e09 | ||
|
|
e7699befc5 | ||
|
|
44d6e10ea2 | ||
|
|
f5de074ea1 | ||
|
|
8e74d0b516 | ||
|
|
e95ac086f0 | ||
|
|
6a78257d71 | ||
|
|
7a67ba0743 | ||
|
|
bc566b1fc0 |
No files matched your search
@@ -109,7 +109,10 @@ Un motor de búsqueda dedicado (Meilisearch, autohospedado) queda
|
|||||||
identificado como el siguiente paso natural una vez el catálogo
|
identificado como el siguiente paso natural una vez el catálogo
|
||||||
crezca más allá de ~100 productos activos, pero no es parte del
|
crezca más allá de ~100 productos activos, pero no es parte del
|
||||||
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
|
alcance inmediato de Etapa 2 — se revisará cuando ese techo esté
|
||||||
cerca.
|
cerca. **Actualización 2026-08-25:** terminada la Etapa 2 (ver
|
||||||
|
[ADR 0005](0005-rediseno-storefront.md)), el disparador real resultó
|
||||||
|
ser la complejidad de facetas de la taxonomía, no el volumen de
|
||||||
|
productos — ver [ADR 0006](0006-motor-busqueda-facetas.md).
|
||||||
|
|
||||||
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
|
**Compatibilidad con Medusa Admin confirmada:** ninguna de las tres
|
||||||
opciones evaluadas, incluida la elegida, toca
|
opciones evaluadas, incluida la elegida, toca
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# ADR 0005 — Rediseño del storefront para catálogo multi-categoría
|
# ADR 0005 — Rediseño del storefront para catálogo multi-categoría
|
||||||
|
|
||||||
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2
|
**Estado:** aceptada — Etapa 2 implementada y en producción
|
||||||
**Fecha:** 2026-08-23
|
**Fecha:** 2026-08-23 (Etapa 1) — actualizado 2026-08-25 (Etapa 2 completa)
|
||||||
|
|
||||||
> Este documento es la propuesta de Etapa 1 del rediseño de ARI
|
> Este documento arrancó como la propuesta de Etapa 1 del rediseño de
|
||||||
> Shopping. No incluye código de producción — solo auditoría,
|
> ARI Shopping (auditoría, decisión — ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md) —
|
||||||
> decisión (ver también [ADR 0004](0004-evaluacion-stack-multi-categoria.md)),
|
> patrones de UX y datos demo). Etapa 2 fue aprobada y se implementó
|
||||||
> patrones de UX y datos demo ya creados y visibles en producción
|
> completa en tres sub-fases (datos/categorías, navegación,
|
||||||
> como línea base.
|
> componentes visuales) — ver la sección 6 para el detalle de cada
|
||||||
|
> sub-fase y sus PRs.
|
||||||
|
|
||||||
## 1. Resumen de la auditoría
|
## 1. Resumen de la auditoría
|
||||||
|
|
||||||
@@ -255,12 +256,33 @@ objetivo es validar la tubería Admin → MinIO → NPM → frontend, no el
|
|||||||
contenido visual final.
|
contenido visual final.
|
||||||
|
|
||||||
Usuario admin temporal `[email protected]` usado para esta
|
Usuario admin temporal `[email protected]` usado para esta
|
||||||
auditoría — pendiente de borrar una vez aprobada esta propuesta.
|
auditoría — pendiente de borrar (ver sección 6).
|
||||||
|
|
||||||
## 6. Próximos pasos
|
## 6. Etapa 2 — implementada (2026-08-23 a 2026-08-25)
|
||||||
|
|
||||||
Pendiente de tu aprobación explícita antes de iniciar Etapa 2. Cuando
|
Aprobada y ejecutada en tres sub-fases, cada una validada en vivo
|
||||||
apruebes, Etapa 2 se dividirá en sub-fases (datos/categorías primero,
|
contra estos 9 productos demo:
|
||||||
luego navegación, luego componentes visuales), validando en cada paso
|
|
||||||
contra estos 9 productos demo que precio/descripción/imágenes siguen
|
1. **Datos/categorías** (2026-08-23/24): fix del bug de Store API que
|
||||||
actualizándose correctamente desde Admin.
|
impedía ver la categoría real de un producto (causa raíz del ADR
|
||||||
|
0004); 21 categorías/9 productos demo creados; `legacyProductMeta`
|
||||||
|
eliminado (PR #29) — 17 productos legacy con categoría/colección/
|
||||||
|
marca/rango de edad reales.
|
||||||
|
2. **Navegación** (2026-08-25): navbar y tiles del home pasaron de
|
||||||
|
arrays hardcodeados a data-driven contra el árbol real de
|
||||||
|
categorías (`getCategories()`, PR #32); guard de CI actualizado
|
||||||
|
(PR #33); fix de tipos (PR #34); home/layout forzados a render
|
||||||
|
dinámico para evitar que quedaran cacheados con datos mock (PR #35).
|
||||||
|
3. **Componentes visuales** (2026-08-25), los 4 patrones de la
|
||||||
|
sección 3 más el breadcrumb/facetas de Amazon: mega-menú
|
||||||
|
"Departamentos" multi-columna (PR #36), badges antes/ahora
|
||||||
|
(PR #37), swatches de variante inline (PR #38), mensaje de
|
||||||
|
urgencia por inventario real (PR #39), breadcrumb real + conteos
|
||||||
|
por filtro en el catálogo (PR #40), tiles promocionales
|
||||||
|
intercalados en la grilla (PR #41, con fix-forward de tipos en
|
||||||
|
PR #42).
|
||||||
|
|
||||||
|
**Pendiente:** borrar el usuario admin temporal `[email protected]`
|
||||||
|
de Medusa Admin (creado para la auditoría de la sección 5, ya no
|
||||||
|
necesario). Sin próxima etapa definida todavía — este ADR no
|
||||||
|
especifica una Etapa 3.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# ADR 0006 — Motor de búsqueda y filtros facetados (Fase 5, Etapa 3)
|
||||||
|
|
||||||
|
**Estado:** propuesta — pendiente de aprobación antes de Etapa 2 (implementación)
|
||||||
|
**Fecha:** 2026-08-25
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
[ADR 0004](0004-evaluacion-stack-multi-categoria.md) ya identificó un
|
||||||
|
motor de búsqueda dedicado (Meilisearch, autohospedado) como "el
|
||||||
|
siguiente paso natural" para ARI Shopping, pero lo dejó fuera del
|
||||||
|
alcance de Etapa 2 explícitamente hasta que el catálogo se acercara al
|
||||||
|
techo real de ~100 productos activos que impone
|
||||||
|
`fetchProducts(filters, limit = 100)` en `medusa.ts`.
|
||||||
|
|
||||||
|
**Ese techo todavía no llegó** — el catálogo real hoy son 26 productos
|
||||||
|
(17 legacy + 9 demo, ver [ADR 0005](0005-rediseno-storefront.md)
|
||||||
|
sección 5), lejos de 100. El disparador de esta propuesta no es el
|
||||||
|
techo de volumen sino la **complejidad de facetas**: la Etapa 2 ya
|
||||||
|
construyó una taxonomía real de 6 categorías raíz / 21 nodos
|
||||||
|
(navegación, mega-menú, breadcrumb — PRs #36-42) y un filtrado con
|
||||||
|
conteo por valor (`categoryCounts`/`brandCounts`/`ageCounts` en
|
||||||
|
`catalog-client.tsx`, PR #40) que hoy se calcula **100% client-side**
|
||||||
|
sobre los productos ya traídos — el mismo patrón de "deuda de
|
||||||
|
alcance" que el hallazgo #4 de ADR 0004 ya señaló, ahora con más
|
||||||
|
dimensiones de filtro que multiplicar. Sigue siendo tratable a 26
|
||||||
|
productos; deja de serlo mucho antes de llegar a 100 si el catálogo
|
||||||
|
crece a la vez en número de productos y en dimensiones filtrables.
|
||||||
|
|
||||||
|
Restricción no negociable, igual que en toda la Fase 5: el flujo
|
||||||
|
**Admin → evento `product.updated` → `revalidate-storefront.ts` →
|
||||||
|
`revalidateTag("medusa-products")`**, que hoy mantiene el storefront
|
||||||
|
sincronizado sin intervención manual, no se toca. Cualquier índice de
|
||||||
|
búsqueda debe sincronizarse por el mismo mecanismo de eventos, nunca
|
||||||
|
por un cron ni un paso manual.
|
||||||
|
|
||||||
|
## 1. Estado real de la integración Medusa v2 + Meilisearch
|
||||||
|
|
||||||
|
Verificado contra la documentación oficial vigente el 2026-08-25, no
|
||||||
|
contra memoria de entrenamiento:
|
||||||
|
|
||||||
|
- **Medusa no tiene un módulo de búsqueda propio integrado con
|
||||||
|
Meilisearch hoy.** La guía oficial
|
||||||
|
(`docs.medusajs.com/resources/integrations/guides/meilisearch`) es
|
||||||
|
un tutorial de módulo custom desde cero (`npm install meilisearch` +
|
||||||
|
workflows/subscribers propios) — no un paquete instalable.
|
||||||
|
- **Medusa sí introdujo un "Search Module" propio en la versión
|
||||||
|
2.19.0.** El paquete comunitario `@rokmohar/medusa-plugin-meilisearch`
|
||||||
|
(el más usado y mantenido para Meilisearch+Medusa) se conecta a ese
|
||||||
|
Search Module desde su versión `^2.0.0`, pero esa versión del plugin
|
||||||
|
**requiere Medusa `^2.19.0`**.
|
||||||
|
- **Nuestro backend corre Medusa `2.18.0`** (`commerce-backend/package.json`)
|
||||||
|
— justo por debajo de ese corte. La tabla de compatibilidad del
|
||||||
|
plugin (`github.com/rokmohar/medusa-plugin-meilisearch`) resuelve
|
||||||
|
esto: la línea `^1.4.1` soporta `Medusa >= 2.15 < 2.19`, servidor
|
||||||
|
Meilisearch `>= 1.5`, Node `>= 22`. El propio repo lo explica: *"The
|
||||||
|
2.19 release removed the search interface the v1 line was written
|
||||||
|
against, so the two lines do not overlap: v1 stops at Medusa 2.18,
|
||||||
|
v2 starts at 2.19."*
|
||||||
|
- Node de `commerce-backend` ya es `22.18.0` (Dockerfile) — cumple el
|
||||||
|
mínimo del plugin sin cambios.
|
||||||
|
|
||||||
|
**Implicación concreta:** para adoptar Meilisearch sin subir de
|
||||||
|
versión Medusa (fuera de alcance de este ADR — sería su propia
|
||||||
|
migración), la pieza a instalar es `@rokmohar/medusa-plugin-meilisearch@^1.4.1`,
|
||||||
|
no la última versión del plugin. Esto es deuda técnica documentada,
|
||||||
|
no un obstáculo: si en el futuro se sube Medusa a `2.19+`, migrar a la
|
||||||
|
línea `^2.0.0` del plugin (y al Search Module nativo) es el camino
|
||||||
|
recto.
|
||||||
|
|
||||||
|
## 2. Impacto de recursos
|
||||||
|
|
||||||
|
Estado real del host ZimaOS al momento de esta auditoría (`free -h`,
|
||||||
|
`docker stats`): 15 GiB RAM totales, **swap al 100% (5.2/5.2 GiB
|
||||||
|
usados)**, solo 351 MiB realmente libres (6.5 GiB "available" vía
|
||||||
|
cache reclamable), 4 vCPU — la misma clase de contención que ya forzó
|
||||||
|
el fix de `cpu_shares` de Gitea. Cualquier servicio nuevo en este host
|
||||||
|
es una decisión de capacidad real, no teórica.
|
||||||
|
|
||||||
|
- **Meilisearch no publica un mínimo de RAM oficial.** Su propio FAQ
|
||||||
|
(`meilisearch.com/docs/resources/help/faq`) solo dice que "más RAM
|
||||||
|
implica búsquedas más rápidas" y recomienda ~10x el tamaño del
|
||||||
|
dataset crudo en disco — sin piso duro. La cifra de "~1 GiB
|
||||||
|
cómodo" que circula en blogs de la comunidad es una estimación, no
|
||||||
|
una garantía del proveedor — se cita aquí como referencia, no como
|
||||||
|
hecho verificado.
|
||||||
|
- Meilisearch usa almacenamiento memory-mapped: el índice puede vivir
|
||||||
|
parcialmente en disco cuando no entra en RAM, degradando latencia en
|
||||||
|
vez de fallar. A la escala actual (26 productos, dataset de KB) el
|
||||||
|
uso real de RAM del índice en sí es insignificante — el costo es el
|
||||||
|
proceso base de Meilisearch, no el dataset.
|
||||||
|
- **Comparación con Typesense** (pedida explícitamente, mantenida
|
||||||
|
breve porque la diferencia relevante para este caso es clara y no
|
||||||
|
requiere más profundidad): Typesense mantiene el índice completo en
|
||||||
|
RAM (~30% más RAM que Meilisearch para el mismo dataset, según
|
||||||
|
benchmarks públicos) y, a diferencia de Meilisearch, **no tiene
|
||||||
|
ningún plugin ni integración con Medusa** — habría que escribir la
|
||||||
|
sincronización completa desde cero igual que con la guía "custom"
|
||||||
|
de Meilisearch, pero sin ninguna base comunitaria mantenida
|
||||||
|
compatible con nuestra versión de Medusa. Con Meilisearch ya
|
||||||
|
teniendo un plugin activo para `Medusa 2.18` y menor huella de RAM,
|
||||||
|
no hay motivo para evaluar Typesense más a fondo en este caso.
|
||||||
|
- **Recomendación:** desplegar Meilisearch en k3d con `requests`/`limits`
|
||||||
|
explícitos y conservadores desde el día uno (ej. `request: 256Mi/0.1
|
||||||
|
CPU`, `limit: 768Mi/0.5 CPU` — a ajustar con datos reales de uso, no
|
||||||
|
supuestos), y tratarlo como el mismo tipo de riesgo operativo que ya
|
||||||
|
se vivió con Gitea: monitorear consumo real post-despliegue antes de
|
||||||
|
darlo por estable. Dado el swap ya saturado, este es el punto de
|
||||||
|
mayor incertidumbre de toda la propuesta — más que la elección de
|
||||||
|
motor de búsqueda en sí.
|
||||||
|
|
||||||
|
## 3. Modelo de indexación
|
||||||
|
|
||||||
|
Basado en la taxonomía real ya viva en Medusa (Etapa 2) y en
|
||||||
|
`Product`/`Category` de `lib/headless/types.ts`:
|
||||||
|
|
||||||
|
| Campo | Buscable (texto, tolerante a typos) | Filtrable / faceta | Ordenable |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | Sí | No | No |
|
||||||
|
| `shortDescription` / `description` | Sí | No | No |
|
||||||
|
| `category` (nombre de la hoja real) | Sí | Sí (con conteo) | No |
|
||||||
|
| `collection` | Sí | Sí (con conteo) | No |
|
||||||
|
| `brand` | Sí | Sí (con conteo) | No |
|
||||||
|
| `ageRange` | No | Sí (con conteo) | No |
|
||||||
|
| `tags` | Sí | No | No |
|
||||||
|
| `price` | No | Sí (rango) | Sí |
|
||||||
|
| `compareAtPrice` | No | No | No (solo se usa para el badge, no como filtro) |
|
||||||
|
| `isNew` / `featured` | No | No | Sí (orden "Novedades"/"Destacados", igual que hoy) |
|
||||||
|
| `available` | No | Sí (booleano) | No |
|
||||||
|
| `slug`, `thumbnail`, `id` | No | No | No (solo se devuelven, no se indexan como facetas) |
|
||||||
|
|
||||||
|
La jerarquía de categorías (raíz/hija) no se indexa como un campo
|
||||||
|
aparte: el índice guarda el nombre de la hoja real tal como hoy lo
|
||||||
|
hace `product.category`, y el breadcrumb/mega-menú siguen resolviendo
|
||||||
|
la jerarquía contra `/store/product-categories` (sin cambios, ya
|
||||||
|
funciona desde la sub-fase 2). El motor de búsqueda resuelve
|
||||||
|
**búsqueda de texto + conteo de facetas**, no reemplaza la fuente de
|
||||||
|
la jerarquía.
|
||||||
|
|
||||||
|
## 4. Diseño de sincronización
|
||||||
|
|
||||||
|
Mismo patrón que `revalidate-storefront.ts`, no uno nuevo: un
|
||||||
|
subscriber adicional en `commerce-backend/src/subscribers/` (ej.
|
||||||
|
`sync-search-index.ts`) que reacciona a eventos y le pide al plugin
|
||||||
|
que reindexe el documento afectado — nunca Medusa escribiendo
|
||||||
|
directamente en Meilisearch fuera de este canal.
|
||||||
|
|
||||||
|
Eventos a escuchar, confirmados en la documentación oficial de
|
||||||
|
eventos de Medusa (`docs.medusajs.com/resources/events-reference`):
|
||||||
|
|
||||||
|
- `product.created` / `product.updated` / `product.deleted`
|
||||||
|
- `product-variant.created` / `product-variant.updated` / `product-variant.deleted`
|
||||||
|
(mismo set que ya usa `revalidate-storefront.ts`, hoy validado en
|
||||||
|
producción)
|
||||||
|
- **Nuevo respecto al subscriber actual:** `product-category.created`
|
||||||
|
/ `product-category.updated` / `product-category.deleted` — un
|
||||||
|
cambio de nombre o jerarquía de categoría debe refrescar las
|
||||||
|
facetas del índice, y hoy `revalidate-storefront.ts` no escucha
|
||||||
|
estos eventos porque el storefront los resuelve en cada request
|
||||||
|
contra `/store/product-categories` directamente, sin necesidad de
|
||||||
|
revalidación. El índice de búsqueda sí necesita enterarse.
|
||||||
|
|
||||||
|
**Punto a verificar en vivo en Etapa 2, no asumido aquí:** existe al
|
||||||
|
menos un reporte público de un evento `product-collection.updated`
|
||||||
|
que no se disparaba de forma confiable en ciertas versiones de Medusa
|
||||||
|
(`github.com/medusajs/medusa/issues/11197`). Antes de dar por cerrado
|
||||||
|
el diseño de sincronización hay que repetir exactamente la validación
|
||||||
|
que ya se hizo para `revalidate-storefront.ts` en la auditoría de
|
||||||
|
Etapa 1 de Fase 5: crear/editar una categoría real vía Admin y
|
||||||
|
confirmar en vivo que el evento llega al subscriber, no confiar en que
|
||||||
|
la documentación coincide con el comportamiento real de `2.18.0`
|
||||||
|
(el mismo tipo de brecha que ya se encontró una vez con la relación
|
||||||
|
`+categories` de `/store/products`).
|
||||||
|
|
||||||
|
## 5. Diseño de UI — filtros facetados y búsqueda
|
||||||
|
|
||||||
|
**Filtros facetados (sidebar de `/catalog`):** mismo layout que ya
|
||||||
|
construyó la sub-fase 3 (`catalog-client.tsx`, PR #40) — sidebar con
|
||||||
|
"Categoría"/"Marca"/"Edad" como pills con conteo entre paréntesis y
|
||||||
|
slider de precio máximo. Lo único que cambia es **de dónde sale el
|
||||||
|
conteo**: hoy `categoryCounts`/`brandCounts`/`ageCounts` se calculan
|
||||||
|
recorriendo el arreglo de productos ya traído en el cliente; con el
|
||||||
|
motor de búsqueda, esos conteos vienen del propio Meilisearch
|
||||||
|
(`facetDistribution`) en la misma respuesta que trae los productos
|
||||||
|
filtrados, sin recorrer nada en el navegador. El breadcrumb no cambia
|
||||||
|
— sigue resolviéndose contra la jerarquía real de categorías, no
|
||||||
|
contra el índice de búsqueda.
|
||||||
|
|
||||||
|
```
|
||||||
|
Inicio > Mochilas y morrales > Marvel breadcrumb real (sin cambios)
|
||||||
|
┌────────────┬──────────────────────────────────────┐
|
||||||
|
│ Categoría │ [buscador con autocompletado] │ ← nuevo
|
||||||
|
│ ☐ Marvel(6)│ [card][card][card] │
|
||||||
|
│ ☐ Frozen(4)│ [card][card][card] │
|
||||||
|
│ Marca │ │
|
||||||
|
│ ☐ Disney(2)│ conteos ahora vienen de Meilisearch, │
|
||||||
|
│ Precio │ no de recorrer el arreglo en cliente │
|
||||||
|
│ [====○===] │ │
|
||||||
|
└────────────┴──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Búsqueda con autocompletado (barra del navbar):** hoy
|
||||||
|
`PredictiveSearch` (`components/search/predictive-search.tsx`) filtra
|
||||||
|
por substring sobre los productos ya cargados en memoria del cliente —
|
||||||
|
funciona a 26 productos, no escala y no tolera errores de tipeo (ADR
|
||||||
|
0004, hallazgo #5). Se reemplaza la fuente de datos del mismo
|
||||||
|
componente visual por `searchProducts(query)` del `CatalogProvider`
|
||||||
|
(interfaz que **ya existe** en `lib/headless/types.ts`, sin necesidad
|
||||||
|
de un tipo nuevo) apuntando a Meilisearch en vez de al `?q=` de
|
||||||
|
Medusa: tolerancia a typos y ranking por relevancia nativos del motor,
|
||||||
|
mismo contrato de datos que ya consume el dropdown de resultados —
|
||||||
|
cero cambio de componente React, solo de qué hay detrás de
|
||||||
|
`CatalogProvider.searchProducts`.
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| **Meilisearch autohospedado** (`@rokmohar/medusa-plugin-meilisearch@^1.4.1`, sincronizado por eventos) | Plugin comunitario activo y compatible con nuestra versión exacta de Medusa (2.18); menor huella de RAM que la alternativa evaluada; almacenamiento memory-mapped se degrada en vez de fallar bajo presión de memoria; mismo patrón de subscriber que `revalidate-storefront.ts`, cero riesgo para ese flujo | Ningún paquete "oficial" de Medusa (el plugin es comunitario, no del core); atado a la línea `v1` del plugin mientras no se suba Medusa a 2.19+; un servicio más para operar en un host ya con swap saturado |
|
||||||
|
| **Typesense autohospedado** | Autocompletado ~2x más rápido según benchmarks públicos | Cero integración con Medusa (habría que escribir la sincronización completa desde cero, igual esfuerzo que la guía "custom" de Meilisearch pero sin base comunitaria); ~30% más RAM que Meilisearch para el mismo dataset, el peor trade-off posible dado el estado actual del host |
|
||||||
|
| **Módulo custom desde cero** (seguir la guía oficial de Medusa, sin el plugin de rokmohar) | Sin dependencia de un paquete de terceros; control total | Reimplementa lo que el plugin comunitario ya resuelve (declaración de índices, reindexado atómico, tracking de escrituras); más superficie propia para mantener sin ganancia clara sobre usar el plugin ya compatible con 2.18 |
|
||||||
|
| **No adoptar motor de búsqueda todavía** (seguir con filtrado 100% client-side) | Cero riesgo, cero componente nuevo | No resuelve la deuda de facetas ya identificada en ADR 0004 y agravada por la sub-fase 3; el techo de complejidad de filtros llega antes que el de 100 productos si el catálogo sigue creciendo en categorías |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**Meilisearch autohospedado**, vía `@rokmohar/medusa-plugin-meilisearch@^1.4.1`
|
||||||
|
(no la última versión del plugin — esa exige Medusa 2.19+, fuera de
|
||||||
|
alcance), sincronizado por el mismo patrón de subscriber por eventos
|
||||||
|
que ya usa `revalidate-storefront.ts`, con `product-category.*` sumado
|
||||||
|
al set de eventos existente. Se despliega en k3d con límites de
|
||||||
|
recursos conservadores desde el primer día y se trata como
|
||||||
|
experimento a monitorear, no como capacidad garantizada, dado el
|
||||||
|
estado actual de memoria del host.
|
||||||
|
|
||||||
|
**No se sube la versión de Medusa como parte de esta decisión** —
|
||||||
|
queda como deuda técnica documentada para cuando se justifique
|
||||||
|
migrar a la línea `v2` del plugin y al Search Module nativo de
|
||||||
|
Medusa.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Se gana: conteo de facetas real (no client-side), búsqueda tolerante
|
||||||
|
a errores de tipeo con ranking por relevancia, y una arquitectura
|
||||||
|
que ya no depende del techo de 100 productos de `fetchProducts`.
|
||||||
|
- Se acepta como deuda técnica: quedar atado a la línea `v1` del
|
||||||
|
plugin de Meilisearch mientras Medusa siga en `2.18.x`; migrar a
|
||||||
|
Medusa `2.19+` es un proyecto aparte, no parte de esta Etapa 3.
|
||||||
|
- Riesgo operativo real y no resuelto todavía por este ADR: el host ya
|
||||||
|
corre con el swap saturado — el despliegue de Etapa 2 debe empezar
|
||||||
|
con límites de recursos conservadores y validación en vivo de
|
||||||
|
consumo real antes de confiar en el servicio, siguiendo la misma
|
||||||
|
lección que dejó el incidente de recursos de Gitea.
|
||||||
|
- Revertir esta decisión implica desinstalar el plugin y el
|
||||||
|
subscriber nuevo, y volver el buscador/filtrado a 100% client-side
|
||||||
|
— no hay migración de datos que deshacer, porque Meilisearch nunca
|
||||||
|
es la fuente de verdad, solo una copia de solo lectura indexada
|
||||||
|
desde Medusa.
|
||||||
@@ -91,6 +91,7 @@ nav:
|
|||||||
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
||||||
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
- "0004 · Stack multi-categoría": decisiones/0004-evaluacion-stack-multi-categoria.md
|
||||||
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
- "0005 · Rediseño storefront": decisiones/0005-rediseno-storefront.md
|
||||||
|
- "0006 · Motor de búsqueda y facetas": decisiones/0006-motor-busqueda-facetas.md
|
||||||
- Plantilla ADR: decisiones/plantilla-adr.md
|
- Plantilla ADR: decisiones/plantilla-adr.md
|
||||||
- Playbooks:
|
- Playbooks:
|
||||||
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsNonRoot: false
|
runAsNonRoot: false
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.125
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.159
|
||||||
# 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
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
|||||||
<CatalogClient
|
<CatalogClient
|
||||||
key={`${category}:${collection}:${query}`}
|
key={`${category}:${collection}:${query}`}
|
||||||
products={products}
|
products={products}
|
||||||
|
categories={categories}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
initialCategory={category}
|
initialCategory={category}
|
||||||
initialCollection={collection}
|
initialCollection={collection}
|
||||||
|
|||||||
@@ -1,22 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SlidersHorizontal, X } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { ChevronRight, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { PromoTile } from "@/components/home/category-tiles";
|
||||||
import { ProductCard } from "@/components/product/product-card";
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Product } from "@/lib/headless/types";
|
import type { Category, Product } from "@/lib/headless/types";
|
||||||
|
|
||||||
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
|
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
||||||
|
// promocionales intercalados, no solo tarjetas homogeneas.
|
||||||
|
const PROMO_TILE_INTERVAL = 8;
|
||||||
|
|
||||||
|
type GridItem = { type: "product"; product: Product } | { type: "promo"; category: Category };
|
||||||
|
|
||||||
type CatalogClientProps = {
|
type CatalogClientProps = {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
|
categories?: Category[];
|
||||||
categoryGroups?: Record<string, string[]>;
|
categoryGroups?: Record<string, string[]>;
|
||||||
initialCategory?: string;
|
initialCategory?: string;
|
||||||
initialCollection?: string;
|
initialCollection?: string;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function matchesQuery(product: Product, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
||||||
|
return search.includes(query.toLowerCase());
|
||||||
|
}
|
||||||
|
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
|
||||||
|
return allowedCategories.includes(product.category);
|
||||||
|
}
|
||||||
|
function matchesCollection(product: Product, collection: string): boolean {
|
||||||
|
return !collection || product.collection === collection;
|
||||||
|
}
|
||||||
|
function matchesBrand(product: Product, brand: string): boolean {
|
||||||
|
return brand === "Todas" || product.brand === brand;
|
||||||
|
}
|
||||||
|
function matchesAge(product: Product, age: string): boolean {
|
||||||
|
return age === "Todas" || product.ageRange === age;
|
||||||
|
}
|
||||||
|
function matchesPrice(product: Product, maxPrice: number): boolean {
|
||||||
|
return product.price === null || maxPrice === 0 || product.price <= maxPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camina parentId hacia arriba desde la categoria seleccionada para armar
|
||||||
|
// el breadcrumb real (Inicio > raiz > hija), igual que en Amazon.
|
||||||
|
function buildBreadcrumb(categories: Category[], name: string): Category[] {
|
||||||
|
const node = categories.find((category) => category.name === name);
|
||||||
|
if (!node) return [];
|
||||||
|
const trail = [node];
|
||||||
|
let current = node;
|
||||||
|
while (current.parentId) {
|
||||||
|
const parent = categories.find((category) => category.id === current.parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
trail.unshift(parent);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return trail;
|
||||||
|
}
|
||||||
|
|
||||||
export function CatalogClient({
|
export function CatalogClient({
|
||||||
products,
|
products,
|
||||||
|
categories: categoryNodes = [],
|
||||||
categoryGroups = {},
|
categoryGroups = {},
|
||||||
initialCategory = "Todos",
|
initialCategory = "Todos",
|
||||||
initialCollection = "",
|
initialCollection = "",
|
||||||
@@ -55,29 +103,25 @@ export function CatalogClient({
|
|||||||
// 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 = categoryGroups[category] ?? [category];
|
||||||
|
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
|
||||||
|
// No promociona la misma categoria raiz que el usuario ya esta viendo.
|
||||||
|
const promoRoots = useMemo(() => {
|
||||||
|
const excludeRootId = breadcrumb[0]?.id;
|
||||||
|
return categoryNodes.filter((node) => !node.parentId && node.id !== excludeRootId);
|
||||||
|
}, [categoryNodes, breadcrumb]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() =>
|
() =>
|
||||||
products
|
products
|
||||||
.filter((product) => {
|
.filter(
|
||||||
const search = [
|
(product) =>
|
||||||
product.name,
|
matchesQuery(product, query) &&
|
||||||
product.category,
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
product.collection,
|
matchesCollection(product, collection) &&
|
||||||
product.brand,
|
matchesBrand(product, brand) &&
|
||||||
]
|
matchesAge(product, age) &&
|
||||||
.join(" ")
|
matchesPrice(product, maxPrice),
|
||||||
.toLowerCase();
|
)
|
||||||
|
|
||||||
return (
|
|
||||||
(!query || search.includes(query.toLowerCase())) &&
|
|
||||||
(category === "Todos" || allowedCategories.includes(product.category)) &&
|
|
||||||
(!collection || product.collection === collection) &&
|
|
||||||
(brand === "Todas" || product.brand === brand) &&
|
|
||||||
(age === "Todas" || product.ageRange === age) &&
|
|
||||||
(product.price === null || maxPrice === 0 || product.price <= maxPrice)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
sort === "name"
|
sort === "name"
|
||||||
? a.name.localeCompare(b.name, "es")
|
? a.name.localeCompare(b.name, "es")
|
||||||
@@ -88,6 +132,78 @@ export function CatalogClient({
|
|||||||
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
|
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
|
||||||
|
// ciclando por las categorias raiz -- si no hay jerarquia de
|
||||||
|
// categorias disponible, la grilla queda igual que antes (solo
|
||||||
|
// productos).
|
||||||
|
const gridItems = useMemo<GridItem[]>(() => {
|
||||||
|
if (promoRoots.length === 0) return filtered.map((product) => ({ type: "product", product }));
|
||||||
|
|
||||||
|
const items: GridItem[] = [];
|
||||||
|
let promoIndex = 0;
|
||||||
|
filtered.forEach((product, index) => {
|
||||||
|
items.push({ type: "product", product });
|
||||||
|
const isLast = index === filtered.length - 1;
|
||||||
|
const promoCategory = promoRoots[promoIndex % promoRoots.length];
|
||||||
|
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0 && promoCategory) {
|
||||||
|
items.push({ type: "promo", category: promoCategory });
|
||||||
|
promoIndex += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, [filtered, promoRoots]);
|
||||||
|
|
||||||
|
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
||||||
|
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
||||||
|
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
||||||
|
// resultados habria si se eligiera ese valor.
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const base = products.filter(
|
||||||
|
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
||||||
|
);
|
||||||
|
const counts: Record<string, number> = { Todos: base.length };
|
||||||
|
for (const name of categories) {
|
||||||
|
if (name === "Todos") continue;
|
||||||
|
const allowed = categoryGroups[name] ?? [name];
|
||||||
|
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
|
const brandCounts = useMemo(() => {
|
||||||
|
const base = products.filter(
|
||||||
|
(product) =>
|
||||||
|
matchesQuery(product, query) &&
|
||||||
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
|
matchesCollection(product, collection) &&
|
||||||
|
matchesAge(product, age) &&
|
||||||
|
matchesPrice(product, maxPrice),
|
||||||
|
);
|
||||||
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
|
for (const value of brands) {
|
||||||
|
if (value === "Todas") continue;
|
||||||
|
counts[value] = base.filter((product) => product.brand === value).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
|
const ageCounts = useMemo(() => {
|
||||||
|
const base = products.filter(
|
||||||
|
(product) =>
|
||||||
|
matchesQuery(product, query) &&
|
||||||
|
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||||
|
matchesCollection(product, collection) &&
|
||||||
|
matchesBrand(product, brand) &&
|
||||||
|
matchesPrice(product, maxPrice),
|
||||||
|
);
|
||||||
|
const counts: Record<string, number> = { Todas: base.length };
|
||||||
|
for (const value of ages) {
|
||||||
|
if (value === "Todas") continue;
|
||||||
|
counts[value] = base.filter((product) => product.ageRange === value).length;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
@@ -101,6 +217,27 @@ export function CatalogClient({
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-10">
|
<div className="mx-auto max-w-7xl px-4 py-10">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
{breadcrumb.length > 0 && (
|
||||||
|
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
|
||||||
|
<Link href="/catalog" prefetch={false} className="hover:text-fuchsia-600">
|
||||||
|
Inicio
|
||||||
|
</Link>
|
||||||
|
{breadcrumb.map((node, index) => (
|
||||||
|
<span key={node.id} className="flex items-center gap-1.5">
|
||||||
|
<ChevronRight className="size-3" />
|
||||||
|
{index === breadcrumb.length - 1 ? (
|
||||||
|
<span aria-current="page" className="text-slate-900">
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-fuchsia-600">
|
||||||
|
{node.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
|
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
|
||||||
Catálogo completo
|
Catálogo completo
|
||||||
</span>
|
</span>
|
||||||
@@ -134,9 +271,10 @@ export function CatalogClient({
|
|||||||
values={categories}
|
values={categories}
|
||||||
selected={category}
|
selected={category}
|
||||||
onChange={setCategory}
|
onChange={setCategory}
|
||||||
|
counts={categoryCounts}
|
||||||
/>
|
/>
|
||||||
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} />
|
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
|
||||||
<Filter title="Edad" values={ages} selected={age} onChange={setAge} />
|
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="mb-3 text-sm font-black">Precio máximo</legend>
|
<legend className="mb-3 text-sm font-black">Precio máximo</legend>
|
||||||
@@ -180,9 +318,13 @@ export function CatalogClient({
|
|||||||
|
|
||||||
{filtered.length ? (
|
{filtered.length ? (
|
||||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{filtered.map((product) => (
|
{gridItems.map((item, index) =>
|
||||||
<ProductCard key={product.id} product={product} />
|
item.type === "product" ? (
|
||||||
))}
|
<ProductCard key={item.product.id} product={item.product} />
|
||||||
|
) : (
|
||||||
|
<PromoTile key={`promo-${index}`} category={item.category} className="min-h-[22rem]" />
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
|
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
|
||||||
@@ -205,11 +347,13 @@ function Filter({
|
|||||||
values,
|
values,
|
||||||
selected,
|
selected,
|
||||||
onChange,
|
onChange,
|
||||||
|
counts,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
selected: string;
|
selected: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
counts?: Record<string, number>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -227,6 +371,7 @@ function Filter({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
|
{counts && <span className="ml-1 opacity-60">({counts[value] ?? 0})</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import type { Category } from "@/lib/headless/types";
|
import type { Category } from "@/lib/headless/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type TileContent = { subtitle: string; image?: string; color: string };
|
type TileContent = { subtitle: string; image?: string; color: string };
|
||||||
|
|
||||||
@@ -20,29 +21,17 @@ const tileContent: Record<string, TileContent> = {
|
|||||||
|
|
||||||
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
|
const defaultTile: TileContent = { subtitle: "Explora esta categoría", color: "from-slate-400 to-slate-600" };
|
||||||
|
|
||||||
export function CategoryTiles({ categories }: { categories: Category[] }) {
|
// Tile individual reutilizado tanto por la seccion de mundos del home
|
||||||
const roots = categories.filter((category) => !category.parentId);
|
// como por el grid del catalogo (tiles promocionales intercalados,
|
||||||
|
// ADR 0005 seccion 3, patron Walmart) -- mismo primitivo visual, no un
|
||||||
if (roots.length === 0) return null;
|
// componente nuevo.
|
||||||
|
export function PromoTile({ category, className = "" }: { category: Category; className?: string }) {
|
||||||
return (
|
|
||||||
<section className="mx-auto max-w-7xl px-4 py-14">
|
|
||||||
<div className="mb-8 flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
|
|
||||||
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
|
|
||||||
</div>
|
|
||||||
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
{roots.map((category) => {
|
|
||||||
const content = tileContent[category.handle] ?? defaultTile;
|
const content = tileContent[category.handle] ?? defaultTile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={category.id}
|
|
||||||
href={`/catalog?category=${encodeURIComponent(category.name)}`}
|
href={`/catalog?category=${encodeURIComponent(category.name)}`}
|
||||||
className={`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`}
|
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`, className)}
|
||||||
>
|
>
|
||||||
{content.image && (
|
{content.image && (
|
||||||
<>
|
<>
|
||||||
@@ -61,7 +50,26 @@ export function CategoryTiles({ categories }: { categories: Category[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
export function CategoryTiles({ categories }: { categories: Category[] }) {
|
||||||
|
const roots = categories.filter((category) => !category.parentId);
|
||||||
|
|
||||||
|
if (roots.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-7xl px-4 py-14">
|
||||||
|
<div className="mb-8 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span>
|
||||||
|
<h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{roots.map((category) => (
|
||||||
|
<PromoTile key={category.id} category={category} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.147
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
Reference in new issue
Block a user