Compare commits

..
Author SHA1 Message Date
gitea-actions b7443a034f chore(gitops): deploy Docs Portal v1.0.173 [skip ci] 2026-08-27 04:46:06 +00:00
devops 4ee892a43a Merge fix/adr0005-cerrar-pendiente-demo-audit into main
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m1s
2026-08-26 23:40:53 -05:00
devops c6c66b76a3 docs(adr0005): cerrar pendiente de usuario admin temporal
[email protected] (creado para la auditoria de Etapa 1) fue
borrado 2026-08-27 via Medusa Admin UI -- ya no queda pendiente
abierto en el ADR.
2026-08-26 23:40:53 -05:00
devops 7406aff34f chore: release v1.0.172 [skip ci] 2026-08-27 04:29:21 +00:00
devops ba3e6c7322 Merge fix/adr0006-etapa2-fase3b-count-limit0-bug into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 17s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m22s
2026-08-26 23:20:40 -05:00
devops 884dbd1d93 fix(ecommerce): total de facetas no depende de count con limit=0
Verificado en vivo contra shop.cruzcloud.net: /store/search reporta
estimatedTotalHits (campo `count`) en 0 cuando limit=0, aunque
facetDistribution si trae los valores correctos (limit>=1 lo reporta
bien). El bucket "Todas"/"Todos" del sidebar de facetas (Fase 3b)
dependia de ese campo -- ahora suma los valores de la propia
facetDistribution, que da el total real sin ese problema.
2026-08-26 23:20:39 -05:00
devops 951682a4d4 chore: release v1.0.171 [skip ci] 2026-08-27 04:06:42 +00:00
devops 6b5450f16f Merge fix/adr0006-etapa2-fase3b-facetas-sidebar into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 37s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m53s
2026-08-26 22:55:13 -05:00
devops 83d448b282 feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo
Fase 3b (ultima) de ADR 0006 Etapa 2: los conteos de categoria/marca/edad
del sidebar de /catalog dejan de ser un snapshot client-side fijo. Ahora
se refetchean en vivo contra /api/search en cada cambio de filtro (query
debounced 300ms), tres llamadas paralelas -- una por dimension, cada una
omitiendo su propio filtro para preservar el patron Amazon de "cuantos
resultados habria si eligiera este otro valor".

- catalog-client.tsx: useMemo client-side originales pasan a ser fallback
  (mock/shopify sin searchWithFacets, o cuando la categoria activa es un
  grupo raiz sintetico que Meilisearch no puede filtrar).
- ProductFilters gana `limit` opcional; /api/search y el provider medusa
  lo reenvian -- las llamadas de solo-conteo piden limit=0 para no
  rehidratar precios de productos que no se van a mostrar.
2026-08-26 22:53:49 -05:00
devops 1ecd2015a4 chore: release v1.0.170 [skip ci] 2026-08-26 22:38:31 +00:00
devops 609fc19252 Merge fix/adr0006-etapa2-fase3a-buscador-navbar into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m16s
2026-08-26 17:30:54 -05:00
gitea-actions 7d2c03f10f chore(gitops): deploy Docs Portal v1.0.169 [skip ci] 2026-08-26 22:16:37 +00:00
7 changed files with 122 additions and 12 deletions

No files matched your search

@@ -282,7 +282,7 @@ contra estos 9 productos demo:
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.
El usuario admin temporal `[email protected]` (creado para la
auditoría de la sección 5) fue borrado el 2026-08-27 vía Medusa Admin.
Sin próxima etapa definida todavía — este ADR no especifica una
Etapa 3.
@@ -25,7 +25,7 @@ spec:
runAsNonRoot: false
containers:
- name: app
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.164
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.173
# El base saca TODAS las capabilities. Probado en vivo y
# revertido: con drop: ALL (aunque se agregue NET_BIND_SERVICE
# de vuelta) el propio entrypoint de nginx:1.27-alpine falla al
@@ -20,6 +20,8 @@ export async function GET(request: Request) {
if (minPrice) filters.minPrice = Number(minPrice);
const maxPrice = searchParams.get("maxPrice");
if (maxPrice) filters.maxPrice = Number(maxPrice);
const limit = searchParams.get("limit");
if (limit) filters.limit = Number(limit);
try {
const result = await searchWithFacets(filters);
@@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles";
import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Category, Product } from "@/lib/headless/types";
import type { Category, FacetDistribution, Product } from "@/lib/headless/types";
// Cada cuantas tarjetas de producto se intercala un tile promocional en
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
@@ -156,8 +156,10 @@ export function CatalogClient({
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
// 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(() => {
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
// el provider activo no soporta facetas reales (mock/shopify) o cuando
// la busqueda en vivo todavia no respondio.
const fallbackCategoryCounts = useMemo(() => {
const base = products.filter(
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
);
@@ -170,7 +172,7 @@ export function CatalogClient({
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
const brandCounts = useMemo(() => {
const fallbackBrandCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
@@ -187,7 +189,7 @@ export function CatalogClient({
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
const ageCounts = useMemo(() => {
const fallbackAgeCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
@@ -204,6 +206,108 @@ export function CatalogClient({
return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
// como valor indexado en Meilisearch (ningun producto lo tiene como
// category real, solo sus hijos -- ver categoryGroups arriba). Mientras
// esa sea la categoria activa, las facetas en vivo de marca/edad no
// pueden filtrar por ella y se degrada al conteo client-side, que si
// sabe expandir el grupo via matchesCategory/allowedCategories.
const categoryIsLeafOrNone = category === "Todos" || !categoryGroups[category];
// Conteos reales via Meilisearch (ADR 0006 Etapa 2 Fase 3b): un fetch
// por dimension contra /api/search, cada uno omitiendo su propio filtro
// (mismo criterio de auto-exclusion que el fallback de arriba) para que
// los conteos reflejen el subconjunto ya filtrado por las OTRAS
// dimensiones y se puedan seguir eligiendo otros valores. `limit=0` evita
// rehidratar precios de productos que no vamos a mostrar -- solo importan
// `facetDistribution` y `count`.
const [liveFacets, setLiveFacets] = useState<{
category: Record<string, number> | null;
categoryTotal: number | null;
brand: Record<string, number> | null;
brandTotal: number | null;
ageRange: Record<string, number> | null;
ageTotal: number | null;
}>({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
useEffect(() => {
const controller = new AbortController();
function buildParams(omit: "category" | "brand" | "ageRange"): URLSearchParams {
const params = new URLSearchParams({ limit: "0" });
if (query) params.set("q", query);
if (collection) params.set("collection", collection);
if (omit !== "category" && category !== "Todos" && categoryIsLeafOrNone) {
params.set("category", category);
}
if (omit !== "brand" && brand !== "Todas") params.set("brand", brand);
if (omit !== "ageRange" && age !== "Todas") params.set("ageRange", age);
if (maxPrice > 0) params.set("maxPrice", String(maxPrice));
return params;
}
async function fetchFacet(omit: "category" | "brand" | "ageRange") {
const response = await fetch(`/api/search?${buildParams(omit).toString()}`, {
signal: controller.signal,
});
return (await response.json()) as { facetDistribution?: FacetDistribution };
}
// El "count"/estimatedTotalHits que devuelve /api/search viene en 0 con
// limit=0 (confirmado en vivo contra Meilisearch real -- limit>=1 lo
// reporta bien, limit=0 no). facetDistribution si es correcto con
// limit=0, y cada producto tiene siempre un valor para cada dimension
// (mapProduct usa defaults como "ARI Shopping"/"Todas", nunca null), asi
// que sumar sus valores da el total real sin depender de ese campo.
function sumFacetValues(facet: Record<string, number> | null): number {
return facet ? Object.values(facet).reduce((sum, value) => sum + value, 0) : 0;
}
const timer = setTimeout(() => {
Promise.all([fetchFacet("category"), fetchFacet("brand"), fetchFacet("ageRange")])
.then(([categoryRes, brandRes, ageRes]) => {
// Un facetDistribution vacio ({}) es la senal de degrade explicita
// de /api/search (provider sin searchWithFacets, o Meilisearch
// sin indices todavia) -- distinto de una faceta real con 0
// resultados, que igual trae la clave presente.
const categoryFacet = categoryRes.facetDistribution && "category" in categoryRes.facetDistribution ? categoryRes.facetDistribution.category : null;
const brandFacet = brandRes.facetDistribution && "brand" in brandRes.facetDistribution ? brandRes.facetDistribution.brand : null;
const ageFacet = ageRes.facetDistribution && "ageRange" in ageRes.facetDistribution ? ageRes.facetDistribution.ageRange : null;
setLiveFacets({
category: categoryFacet,
categoryTotal: categoryFacet ? sumFacetValues(categoryFacet) : null,
brand: categoryIsLeafOrNone ? brandFacet : null,
brandTotal: categoryIsLeafOrNone && brandFacet ? sumFacetValues(brandFacet) : null,
ageRange: categoryIsLeafOrNone ? ageFacet : null,
ageTotal: categoryIsLeafOrNone && ageFacet ? sumFacetValues(ageFacet) : null,
});
})
.catch((error) => {
if ((error as Error).name !== "AbortError") {
setLiveFacets({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null });
}
});
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
const categoryCounts = useMemo(
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
);
const brandCounts = useMemo(
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
);
const ageCounts = useMemo(
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
);
const reset = () => {
setQuery("");
setCategory("Todos");
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.157
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.172
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -473,6 +473,6 @@ export const medusaCatalogProvider: CatalogProvider = {
},
async searchWithFacets(filters) {
return fetchWithFacets(filters);
return fetchWithFacets(filters, filters?.limit ?? 24);
},
};
@@ -38,6 +38,10 @@ export interface ProductFilters {
ageRange?: string;
minPrice?: number;
maxPrice?: number;
// Solo lo usa searchWithFacets -- limit=0 pide facetDistribution/count
// sin rehidratar precios de productos que no se van a mostrar (ver
// sidebar de facetas en catalog-client.tsx).
limit?: number;
}
export interface Category {