Compare commits

..
Author SHA1 Message Date
devops 6a78257d71 chore: release v1.0.149 [skip ci] 2026-08-25 19:42:14 +00:00
4 changed files with 54 additions and 207 deletions

No files matched your search

-1
View File
@@ -41,7 +41,6 @@ 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,70 +1,22 @@
"use client"; "use client";
import Link from "next/link"; import { SlidersHorizontal, X } from "lucide-react";
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 { Category, Product } from "@/lib/headless/types"; import type { 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 = "",
@@ -103,25 +55,29 @@ 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( .filter((product) => {
(product) => const search = [
matchesQuery(product, query) && product.name,
(category === "Todos" || matchesCategory(product, allowedCategories)) && product.category,
matchesCollection(product, collection) && product.collection,
matchesBrand(product, brand) && product.brand,
matchesAge(product, age) && ]
matchesPrice(product, maxPrice), .join(" ")
) .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")
@@ -132,77 +88,6 @@ 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;
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0) {
items.push({ type: "promo", category: promoRoots[promoIndex % promoRoots.length] });
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");
@@ -216,27 +101,6 @@ 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>
@@ -270,10 +134,9 @@ 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} counts={brandCounts} /> <Filter title="Marca" values={brands} selected={brand} onChange={setBrand} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} /> <Filter title="Edad" values={ages} selected={age} onChange={setAge} />
<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>
@@ -317,13 +180,9 @@ 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">
{gridItems.map((item, index) => {filtered.map((product) => (
item.type === "product" ? ( <ProductCard key={product.id} product={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">
@@ -346,13 +205,11 @@ 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>
@@ -370,7 +227,6 @@ function Filter({
}`} }`}
> >
{value} {value}
{counts && <span className="ml-1 opacity-60">({counts[value] ?? 0})</span>}
</button> </button>
))} ))}
</div> </div>
@@ -2,7 +2,6 @@ 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 };
@@ -21,37 +20,6 @@ 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" };
// Tile individual reutilizado tanto por la seccion de mundos del home
// como por el grid del catalogo (tiles promocionales intercalados,
// ADR 0005 seccion 3, patron Walmart) -- mismo primitivo visual, no un
// componente nuevo.
export function PromoTile({ category, className = "" }: { category: Category; className?: string }) {
const content = tileContent[category.handle] ?? defaultTile;
return (
<Link
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={cn(`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`, className)}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
}
export function CategoryTiles({ categories }: { categories: Category[] }) { export function CategoryTiles({ categories }: { categories: Category[] }) {
const roots = categories.filter((category) => !category.parentId); const roots = categories.filter((category) => !category.parentId);
@@ -67,9 +35,33 @@ export function CategoryTiles({ categories }: { categories: Category[] }) {
<Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link> <Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link>
</div> </div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{roots.map((category) => ( {roots.map((category) => {
<PromoTile key={category.id} category={category} /> const content = tileContent[category.handle] ?? defaultTile;
))}
return (
<Link
key={category.id}
href={`/catalog?category=${encodeURIComponent(category.name)}`}
className={`group relative min-h-64 overflow-hidden rounded-[2rem] bg-gradient-to-br ${content.color} shadow-xl`}
>
{content.image && (
<>
<Image src={content.image} alt={category.name} fill sizes="(max-width: 640px) 100vw, (max-width: 1024px) 33vw, 16vw" className="object-cover transition duration-500 group-hover:scale-105" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" />
</>
)}
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white">
<div>
<h3 className="text-xl font-black">{category.name}</h3>
<p className="mt-1 text-sm font-semibold text-white/80">{content.subtitle}</p>
</div>
<span className="grid size-11 shrink-0 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45">
<ArrowUpRight />
</span>
</div>
</Link>
);
})}
</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.149
env: env:
- name: HEADLESS_PROVIDER - name: HEADLESS_PROVIDER
value: medusa value: medusa