Product Grid Page
blockcommerceE-commerce product listing page (PLP) featuring breadcrumb navigation, category header with sorting and mobile filter drawer, a 2-column layout with a multi-facet sidebar (categories, price range slider, color swatches, brands, ratings), and a responsive 3-column product grid with image hover zoom, badges, wishlist toggle, color swatches, quick add to cart, and pagination.
Installation
$pnpm dlx uipkge-ng@latest add product-grid-page$npx uipkge-ng@latest add product-grid-page$yarn dlx uipkge-ng@latest add product-grid-page$bunx uipkge-ng@latest add product-grid-pageManifest:
https://uipkge.dev/r/angular/product-grid-page.jsonInstalls to:src/app/components/blocks/product-grid-page/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CategoryOptioninterface CategoryOption {
id: string
label: string
}BrandOptioninterface BrandOption {
id: string
label: string
}ColorOptioninterface ColorOption {
id: string
label: string
class: string
}ProductColorinterface ProductColor {
id: string
name: string
class: string
colorCode: string
}Productinterface Product {
id: string
name: string
brand: string
category: string
categoryId: string
price: number
originalPrice?: number
rating: number
reviewCount: number
badge?: {
label: string
variant: 'default' | 'secondary' | 'destructive' | 'outline'
}
image: string
colors: ProductColor[]
createdAt: number
featuredRank: number
}npm dependencies
Files installed (2)
src/app/components/blocks/product-grid-page/product-grid-page.component.ts37.5 kB
// E-commerce product listing page: breadcrumb, category header with sort + mobile filter // drawer, multi-facet sidebar (categories, price range, colors, brands, ratings), product // grid with badges / wishlist / quick add, and pagination. Self-contained single-file port // of the Vue ProductGridPage + ProductFilterSidebar + product-filter-data 1:1. import { NgTemplateOutlet } from '@angular/common' import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { Check, ChevronLeft, ChevronRight, Heart, LucideAngularModule, RotateCcw, ShoppingBag, SlidersHorizontal, Star, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiBreadcrumbComponent, UiBreadcrumbItemComponent, UiBreadcrumbLinkComponent, UiBreadcrumbListComponent, UiBreadcrumbPageComponent, UiBreadcrumbSeparatorComponent, } from '@/ui/breadcrumb/breadcrumb.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetHeaderComponent, UiSheetTitleComponent, } from '@/ui/sheet/sheet.component' import { UiSliderComponent } from '@/ui/slider/slider.component' export interface CategoryOption { id: string label: string } export interface BrandOption { id: string label: string } export interface ColorOption { id: string label: string class: string } export const CATEGORIES: CategoryOption[] = [ { id: 'sneakers', label: 'Sneakers' }, { id: 'running', label: 'Running' }, { id: 'boots', label: 'Boots' }, { id: 'loafers', label: 'Loafers' }, ] export const BRANDS: BrandOption[] = [ { id: 'Nike', label: 'Nike' }, { id: 'Adidas', label: 'Adidas' }, { id: 'New Balance', label: 'New Balance' }, { id: 'Salomon', label: 'Salomon' }, ] export const COLORS: ColorOption[] = [ { id: 'black', label: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900 border-zinc-800' }, { id: 'white', label: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border-zinc-300' }, { id: 'sage', label: 'Sage', class: 'bg-chart-2 border-chart-2' }, { id: 'navy', label: 'Navy', class: 'bg-slate-900 border-slate-800' }, { id: 'amber', label: 'Amber', class: 'bg-warning border-warning' }, ] export const RATING_OPTIONS = [ { value: 4.8, label: '4.8 stars & above' }, { value: 4.5, label: '4.5 stars & above' }, { value: 4.0, label: '4.0 stars & above' }, ] interface ProductColor { id: string name: string class: string colorCode: string } interface Product { id: string name: string brand: string category: string categoryId: string price: number originalPrice?: number rating: number reviewCount: number badge?: { label: string variant: 'default' | 'secondary' | 'destructive' | 'outline' } image: string colors: ProductColor[] createdAt: number featuredRank: number } const PRODUCTS: Product[] = [ { id: 'prod-1', name: 'XT-6 GORE-TEX Trail Running Shoe', brand: 'Salomon', category: 'Running', categoryId: 'running', price: 220, originalPrice: 245, rating: 4.9, reviewCount: 128, badge: { label: 'Best Seller', variant: 'default' }, image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c1', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c2', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' }, { id: 'c3', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' }, ], createdAt: 1708000000000, featuredRank: 1, }, { id: 'prod-2', name: 'Air Max Pulse Premium', brand: 'Nike', category: 'Sneakers', categoryId: 'sneakers', price: 150, originalPrice: 180, rating: 4.8, reviewCount: 94, badge: { label: '-20% OFF', variant: 'destructive' }, image: 'https://images.unsplash.com/photo-1552346154-21d32810aba3?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c4', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' }, { id: 'c5', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c6', name: 'Amber', class: 'bg-warning', colorCode: 'amber' }, ], createdAt: 1708500000000, featuredRank: 2, }, { id: 'prod-3', name: '990v6 Made in USA Core Sneaker', brand: 'New Balance', category: 'Sneakers', categoryId: 'sneakers', price: 200, rating: 4.9, reviewCount: 215, badge: { label: 'New', variant: 'secondary' }, image: 'https://images.unsplash.com/photo-1539185441755-769473a23570?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c7', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' }, { id: 'c8', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' }, { id: 'c9', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' }, ], createdAt: 1709000000000, featuredRank: 3, }, { id: 'prod-4', name: 'Ultraboost Light Performance Runner', brand: 'Adidas', category: 'Running', categoryId: 'running', price: 190, originalPrice: 210, rating: 4.7, reviewCount: 86, image: 'https://images.unsplash.com/photo-1587563871167-1ee9c731aefb?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c10', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c11', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' }, ], createdAt: 1707000000000, featuredRank: 4, }, { id: 'prod-5', name: 'Tor Ultra Hi Waterproof Boot', brand: 'Salomon', category: 'Boots', categoryId: 'boots', price: 260, rating: 4.8, reviewCount: 62, badge: { label: 'Trending', variant: 'outline' }, image: 'https://images.unsplash.com/photo-1608231387042-66d1773070a5?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c12', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c13', name: 'Amber', class: 'bg-warning', colorCode: 'amber' }, ], createdAt: 1708800000000, featuredRank: 5, }, { id: 'prod-6', name: 'Penny Lug Leather Loafer', brand: 'Adidas', category: 'Loafers', categoryId: 'loafers', price: 160, originalPrice: 190, rating: 4.5, reviewCount: 41, badge: { label: 'Sale', variant: 'destructive' }, image: 'https://images.unsplash.com/photo-1533867617858-e7b97e060509?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c14', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c15', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' }, ], createdAt: 1706000000000, featuredRank: 6, }, { id: 'prod-7', name: 'Fresh Foam X Trail 574 v2', brand: 'New Balance', category: 'Running', categoryId: 'running', price: 135, originalPrice: 155, rating: 4.6, reviewCount: 78, image: 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c16', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' }, { id: 'c17', name: 'Amber', class: 'bg-warning', colorCode: 'amber' }, { id: 'c18', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' }, ], createdAt: 1707500000000, featuredRank: 7, }, { id: 'prod-8', name: 'Tech Fleece Runner Low Boot', brand: 'Nike', category: 'Boots', categoryId: 'boots', price: 185, rating: 4.7, reviewCount: 53, badge: { label: 'New', variant: 'secondary' }, image: 'https://images.unsplash.com/photo-1514989940723-e8e51635b782?w=700&auto=format&fit=crop&q=80', colors: [ { id: 'c19', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' }, { id: 'c20', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' }, { id: 'c21', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' }, ], createdAt: 1708900000000, featuredRank: 8, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-product-grid-page, [ui-product-grid-page]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ NgTemplateOutlet, LucideAngularModule, UiBadgeComponent, UiBreadcrumbComponent, UiBreadcrumbItemComponent, UiBreadcrumbLinkComponent, UiBreadcrumbListComponent, UiBreadcrumbPageComponent, UiBreadcrumbSeparatorComponent, UiButtonComponent, UiCheckboxComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetHeaderComponent, UiSheetTitleComponent, UiSliderComponent, ], template: ` <ng-template #filters let-prefix="prefix"> <div class="space-y-6"> <!-- Categories --> <div class="space-y-3"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Category</h3> <div class="space-y-2.5"> @for (cat of filterCategories; track cat.id) { <div class="flex items-center justify-between"> <label [for]="prefix + 'cat-' + cat.id" class="text-foreground flex cursor-pointer items-center gap-2 text-sm" > <ui-checkbox [id]="prefix + 'cat-' + cat.id" [checked]="selectedCategories().includes(cat.id)" (checkedChange)="toggleCategory(cat.id, !!$event)" /> <span>{{ cat.label }}</span> </label> <span class="text-muted-foreground text-xs tabular-nums">{{ getCategoryCount(cat.id) }}</span> </div> } </div> </div> <ui-separator /> <!-- Price Range --> <div class="space-y-3"> <div class="flex items-center justify-between"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Price Range</h3> <span class="text-foreground text-xs font-medium tabular-nums"> \${{ priceRange()[0] }} – \${{ priceRange()[1] }} </span> </div> <ui-slider [value]="priceRange()" [min]="50" [max]="300" [step]="5" [range]="true" class="py-2" (valueChange)="setPriceRange($event)" /> <div class="text-muted-foreground flex items-center justify-between text-xs"> <span>$50</span> <span>$300</span> </div> </div> <ui-separator /> <!-- Color Swatches --> <div class="space-y-3"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Color</h3> <div class="flex flex-wrap gap-2"> @for (color of filterColors; track color.id) { <button type="button" [title]="color.label" [class]="filterColorClass(color)" (click)="toggleColor(color.id)" > @if (selectedColors().includes(color.id)) { <lucide-icon [img]="Check" [class]="color.id === 'white' ? 'size-3.5 text-zinc-950' : 'size-3.5 text-white'" /> } <span class="sr-only">{{ color.label }}</span> </button> } </div> </div> <ui-separator /> <!-- Brand --> <div class="space-y-3"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Brand</h3> <div class="space-y-2.5"> @for (b of filterBrands; track b.id) { <div class="flex items-center justify-between"> <label [for]="prefix + 'brand-' + b.id" class="text-foreground flex cursor-pointer items-center gap-2 text-sm" > <ui-checkbox [id]="prefix + 'brand-' + b.id" [checked]="selectedBrands().includes(b.id)" (checkedChange)="toggleBrand(b.id, !!$event)" /> <span>{{ b.label }}</span> </label> <span class="text-muted-foreground text-xs tabular-nums">{{ getBrandCount(b.id) }}</span> </div> } </div> </div> <ui-separator /> <!-- Rating --> <div class="space-y-3"> <h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase"> {{ prefix ? 'Rating' : 'Customer Rating' }} </h3> <div [class]="prefix ? 'space-y-2' : 'space-y-1.5'"> @for (opt of ratingOptions; track opt.value) { <button type="button" [class]="ratingOptionClass(opt.value)" (click)="setRating(opt.value)"> <span class="flex items-center gap-1.5"> <lucide-icon [img]="Star" class="fill-warning text-warning size-3.5" /> <span>{{ opt.label }}</span> </span> @if (minRating() === opt.value) { <lucide-icon [img]="Check" class="text-primary size-3.5" /> } </button> } </div> </div> </div> </ng-template> <div data-slot="product-grid-page" [class]="rootClass"> <!-- Breadcrumb --> <ui-breadcrumb> <ui-breadcrumb-list> <ui-breadcrumb-item> <ui-breadcrumb-link href="#" class="inline-flex min-h-6 items-center">Home</ui-breadcrumb-link> </ui-breadcrumb-item> <ui-breadcrumb-separator /> <ui-breadcrumb-item> <ui-breadcrumb-link href="#" class="inline-flex min-h-6 items-center">Shop</ui-breadcrumb-link> </ui-breadcrumb-item> <ui-breadcrumb-separator /> <ui-breadcrumb-item> <ui-breadcrumb-page>Footwear</ui-breadcrumb-page> </ui-breadcrumb-item> </ui-breadcrumb-list> </ui-breadcrumb> <!-- Header & Toolbar --> <div class="border-border flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between"> <div class="space-y-1"> <div class="flex items-center gap-3"> <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">All Footwear</h1> <span ui-badge variant="secondary" class="font-normal tabular-nums"> {{ filteredProducts().length }} items </span> </div> <p class="text-muted-foreground text-xs sm:text-sm"> Engineered for performance, trail endurance, and everyday urban utility. </p> </div> <div class="flex items-center gap-2"> <!-- Mobile Filter Drawer Trigger --> <div ui-sheet [open]="isMobileFilterOpen()" (openChange)="isMobileFilterOpen.set($event)"> <button ui-button variant="outline" size="sm" class="gap-2 md:hidden" (click)="isMobileFilterOpen.set(true)" > <lucide-icon [img]="SlidersHorizontal" class="size-4" /> <span>Filters</span> @if (activeFilterCount() > 0) { <span ui-badge variant="secondary" class="ml-1 px-1.5 py-0 text-xs"> {{ activeFilterCount() }} </span> } </button> <div ui-sheet-content side="left" class="w-full max-w-xs overflow-y-auto p-6"> <div ui-sheet-header class="text-left"> <div class="flex items-center justify-between"> <h2 ui-sheet-title class="text-base font-semibold">Filters</h2> @if (hasActiveFilters()) { <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal" (click)="resetFilters()" > Reset all </button> } </div> <p ui-sheet-description class="text-xs">Narrow down footwear by category, price, and specs.</p> </div> <div class="mt-6"> <ng-container *ngTemplateOutlet="filters; context: { prefix: 'mobile-' }"></ng-container> </div> </div> </div> <!-- Sort Select --> <div class="flex items-center gap-2"> <span class="text-muted-foreground hidden text-xs sm:inline-block">Sort by:</span> <ui-select [value]="sortBy()" (valueChange)="setSortBy($event)"> <button ui-select-trigger class="h-9 w-44 text-xs sm:text-sm"> <ui-select-value placeholder="Sort by" /> </button> <ui-select-content> <ui-select-item value="featured">Featured</ui-select-item> <ui-select-item value="price-asc">Price: Low to High</ui-select-item> <ui-select-item value="price-desc">Price: High to Low</ui-select-item> <ui-select-item value="rating-desc">Highest Rated</ui-select-item> <ui-select-item value="newest">Newest</ui-select-item> </ui-select-content> </ui-select> </div> </div> </div> <!-- 2-Column Main Layout --> <div class="grid grid-cols-1 gap-8 md:grid-cols-[240px_1fr] lg:grid-cols-[260px_1fr]"> <!-- Left Desktop Sidebar --> <aside class="hidden space-y-6 md:block" aria-label="Product filters"> <div class="flex items-center justify-between"> <h2 class="text-foreground text-sm font-semibold tracking-tight">Filters</h2> @if (hasActiveFilters()) { <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal" (click)="resetFilters()" > Reset all </button> } </div> <ui-separator /> <ng-container *ngTemplateOutlet="filters; context: { prefix: '' }"></ng-container> </aside> <!-- Right Product Grid Area --> <section class="min-w-0 space-y-6"> <!-- Active Filter Chips --> @if (hasActiveFilters()) { <div class="flex flex-wrap items-center gap-2"> @for (catId of selectedCategories(); track catId) { <span ui-badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Category: {{ categoryLabel(catId) }}</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Remove category filter" (click)="toggleCategory(catId, false)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } @for (brand of selectedBrands(); track brand) { <span ui-badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Brand: {{ brand }}</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Remove brand filter" (click)="toggleBrand(brand, false)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } @for (colorId of selectedColors(); track colorId) { <span ui-badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Color: {{ colorLabel(colorId) }}</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Remove color filter" (click)="toggleColor(colorId)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } @if (minRating() > 0) { <span ui-badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Rating: {{ minRating() }}+ Stars</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Remove rating filter" (click)="minRating.set(0)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } @if (priceRange()[0] > 50 || priceRange()[1] < 300) { <span ui-badge variant="secondary" class="gap-1.5 py-1 text-xs"> <span>Price: \${{ priceRange()[0] }} - \${{ priceRange()[1] }}</span> <button type="button" class="hover:text-foreground text-muted-foreground focus-visible:outline-none" aria-label="Reset price filter" (click)="priceRange.set([50, 300])" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } <button ui-button variant="link" size="sm" class="text-muted-foreground hover:text-foreground h-auto px-1 text-xs" (click)="resetFilters()" > Clear all </button> </div> } <!-- Empty State --> @if (filteredProducts().length === 0) { <div class="border-border flex flex-col items-center justify-center rounded-xl border border-dashed py-16 text-center" > <div class="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-full"> <lucide-icon [img]="RotateCcw" class="size-6" /> </div> <h3 class="text-foreground mt-4 text-base font-semibold">No matching footwear found</h3> <p class="text-muted-foreground mt-1 max-w-sm text-xs sm:text-sm"> Try adjusting your price range, clearing selected colors, or resetting your filters to see more results. </p> <button ui-button variant="outline" size="sm" class="mt-4 gap-2" (click)="resetFilters()"> <lucide-icon [img]="RotateCcw" class="size-3.5" /> <span>Reset filters</span> </button> </div> } @else { <!-- 3-Column Product Grid --> <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> @for (product of filteredProducts(); track product.id) { <div class="group border-border bg-card hover:border-border/80 relative flex flex-col overflow-hidden rounded-xl border p-3 shadow-xs transition-colors duration-200 hover:shadow-md" > <div class="bg-muted/40 relative aspect-square w-full overflow-hidden rounded-lg"> <img [src]="product.image" [alt]="product.name" class="h-full w-full object-cover object-center transition-transform duration-500 ease-out group-hover:scale-105" loading="lazy" /> @if (product.badge) { <div class="absolute top-2.5 left-2.5 z-10"> <span ui-badge [variant]="product.badge.variant" class="text-xs shadow-xs"> {{ product.badge.label }} </span> </div> } <button type="button" aria-label="Toggle wishlist" [class]="wishlistClass(product.id)" (click)="toggleWishlist(product.id)" > <lucide-icon [img]="Heart" class="size-4 transition-transform active:scale-90" [class.fill-current]="isWishlisted(product.id)" /> </button> <div class="absolute inset-x-2.5 bottom-2.5 z-10 opacity-100 sm:translate-y-2 sm:opacity-0 sm:transition-colors sm:duration-200 sm:group-hover:translate-y-0 sm:group-hover:opacity-100" > <button ui-button size="sm" class="w-full gap-1.5 text-xs font-medium shadow-sm" (click)="addToCart(product.id)" > @if (isAddedToCart(product.id)) { <lucide-icon [img]="Check" class="size-3.5" /> } @else { <lucide-icon [img]="ShoppingBag" class="size-3.5" /> } <span>{{ isAddedToCart(product.id) ? 'Added to Cart' : 'Quick Add' }}</span> </button> </div> </div> <div class="mt-3 flex flex-1 flex-col justify-between space-y-2"> <div> <div class="flex items-center gap-1.5"> @for (color of product.colors; track color.id) { <button type="button" [title]="color.name" [class]="cardColorClass(product.id, color)" (click)="selectProductColor(product.id, color.colorCode)" > <span class="sr-only">{{ color.name }}</span> </button> } <span class="text-muted-foreground ml-1 text-xs"> {{ product.colors.length }} {{ product.colors.length === 1 ? 'color' : 'colors' }} </span> </div> <div class="text-muted-foreground mt-2 flex items-center justify-between text-xs"> <span>{{ product.brand }} · {{ product.category }}</span> <span class="flex items-center gap-1"> <lucide-icon [img]="Star" class="fill-warning text-warning size-3" /> <span class="text-foreground font-medium">{{ product.rating }}</span> <span class="text-muted-foreground">({{ product.reviewCount }})</span> </span> </div> <h3 class="text-foreground group-hover:text-primary mt-1 line-clamp-1 cursor-pointer text-sm font-medium transition-colors" [title]="product.name" > {{ product.name }} </h3> </div> <div class="flex min-w-0 flex-wrap items-baseline gap-2 pt-1"> <span class="text-foreground text-base font-semibold"> \${{ product.price.toFixed(2) }} </span> @if (product.originalPrice) { <span class="text-muted-foreground text-xs line-through"> \${{ product.originalPrice.toFixed(2) }} </span> <span class="text-success text-xs font-medium"> Save \${{ (product.originalPrice - product.price).toFixed(2) }} </span> } </div> </div> </div> } </div> } <!-- Pagination Footer --> @if (filteredProducts().length > 0) { <nav class="border-border flex items-center justify-between border-t pt-6" aria-label="Pagination"> <button ui-button variant="outline" size="sm" class="gap-1 text-xs" [disabled]="page() === 1" (click)="page.update((p) => Math.max(1, p - 1))" > <lucide-icon [img]="ChevronLeft" class="size-4" /> <span>Previous</span> </button> <div class="flex items-center gap-1"> @for (n of totalPagesArray; track n) { <button ui-button [variant]="n === page() ? 'outline' : 'ghost'" size="sm" class="size-8 p-0 text-xs tabular-nums" [attr.aria-current]="n === page() ? 'page' : null" (click)="page.set(n)" > {{ n }} </button> } </div> <button ui-button variant="outline" size="sm" class="gap-1 text-xs" [disabled]="page() === totalPages" (click)="page.update((p) => Math.min(totalPages, p + 1))" > <span>Next</span> <lucide-icon [img]="ChevronRight" class="size-4" /> </button> </nav> } </section> </div> </div> `, }) export class UiProductGridPageComponent { protected readonly Check = Check protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly Heart = Heart protected readonly RotateCcw = RotateCcw protected readonly ShoppingBag = ShoppingBag protected readonly SlidersHorizontal = SlidersHorizontal protected readonly Star = Star protected readonly X = X protected readonly filterCategories = CATEGORIES protected readonly filterBrands = BRANDS protected readonly filterColors = COLORS protected readonly ratingOptions = RATING_OPTIONS protected readonly totalPages = 3 protected readonly totalPagesArray = [1, 2, 3] protected readonly Math = Math @Input('class') className?: string readonly selectedCategories = signal<string[]>([]) readonly priceRange = signal<[number, number]>([50, 300]) readonly selectedColors = signal<string[]>([]) readonly selectedBrands = signal<string[]>([]) readonly minRating = signal<number>(0) readonly sortBy = signal<string>('featured') readonly wishlistIds = signal<Set<string>>(new Set(['prod-1', 'prod-5'])) readonly addedToCartIds = signal<Set<string>>(new Set()) readonly activeProductColor = signal<Record<string, string>>({}) readonly page = signal(1) readonly isMobileFilterOpen = signal(false) private cartTimers = new Map<string, ReturnType<typeof setTimeout>>() readonly hasActiveFilters = computed( () => this.selectedCategories().length > 0 || this.selectedBrands().length > 0 || this.selectedColors().length > 0 || this.minRating() > 0 || this.priceRange()[0] > 50 || this.priceRange()[1] < 300, ) readonly activeFilterCount = computed(() => { let count = 0 if (this.selectedCategories().length > 0) count += this.selectedCategories().length if (this.selectedBrands().length > 0) count += this.selectedBrands().length if (this.selectedColors().length > 0) count += this.selectedColors().length if (this.minRating() > 0) count += 1 if (this.priceRange()[0] > 50 || this.priceRange()[1] < 300) count += 1 return count }) readonly filteredProducts = computed(() => { const sort = this.sortBy() return PRODUCTS.filter((product) => { if (this.selectedCategories().length > 0 && !this.selectedCategories().includes(product.categoryId)) { return false } if (product.price < this.priceRange()[0] || product.price > this.priceRange()[1]) { return false } if (this.selectedColors().length > 0) { const hasMatchingColor = product.colors.some((c) => this.selectedColors().includes(c.colorCode)) if (!hasMatchingColor) return false } if (this.selectedBrands().length > 0 && !this.selectedBrands().includes(product.brand)) { return false } if (this.minRating() > 0 && product.rating < this.minRating()) { return false } return true }).sort((a, b) => { if (sort === 'price-asc') return a.price - b.price if (sort === 'price-desc') return b.price - a.price if (sort === 'rating-desc') return b.rating - a.rating if (sort === 'newest') return b.createdAt - a.createdAt return a.featuredRank - b.featuredRank }) }) get rootClass(): string { return cn('w-full space-y-8', this.className) } categoryLabel(catId: string): string { return CATEGORIES.find((c) => c.id === catId)?.label ?? catId } colorLabel(colorId: string): string { return COLORS.find((c) => c.id === colorId)?.label ?? colorId } filterColorClass(color: ColorOption): string { return cn( 'focus-visible:ring-ring relative flex items-center justify-center rounded-full border transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none size-7', color.class, this.selectedColors().includes(color.id) ? 'ring-primary ring-offset-background ring-2 ring-offset-2' : 'border-border', ) } ratingOptionClass(value: number): string { return cn( 'hover:bg-muted/60 flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs transition-colors', this.minRating() === value ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground', ) } wishlistClass(productId: string): string { return cn( 'bg-background/80 hover:bg-background text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2.5 right-2.5 z-10 flex size-8 items-center justify-center rounded-full shadow-xs backdrop-blur-xs transition-colors focus-visible:ring-2 focus-visible:outline-none', this.isWishlisted(productId) && 'text-destructive hover:text-destructive', ) } cardColorClass(productId: string, color: ProductColor): string { return cn( 'size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none', color.class, this.activeProductColor()[productId] === color.colorCode && 'ring-1.5 ring-primary ring-offset-background ring-offset-1', ) } toggleCategory(id: string, checked: boolean): void { this.selectedCategories.update((prev) => (checked ? [...prev, id] : prev.filter((item) => item !== id))) this.page.set(1) } toggleBrand(id: string, checked: boolean): void { this.selectedBrands.update((prev) => (checked ? [...prev, id] : prev.filter((item) => item !== id))) this.page.set(1) } toggleColor(id: string): void { this.selectedColors.update((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])) this.page.set(1) } setRating(val: number): void { this.minRating.update((prev) => (prev === val ? 0 : val)) this.page.set(1) } setPriceRange(value: number[]): void { this.priceRange.set([value[0] ?? 50, value[1] ?? 300]) this.page.set(1) } setSortBy(value: string): void { this.sortBy.set(value) } toggleWishlist(id: string): void { this.wishlistIds.update((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } isWishlisted(id: string): boolean { return this.wishlistIds().has(id) } addToCart(id: string): void { clearTimeout(this.cartTimers.get(id)) this.addedToCartIds.update((prev) => new Set(prev).add(id)) this.cartTimers.set( id, setTimeout(() => { this.addedToCartIds.update((prev) => { const next = new Set(prev) next.delete(id) return next }) this.cartTimers.delete(id) }, 1600), ) } isAddedToCart(id: string): boolean { return this.addedToCartIds().has(id) } selectProductColor(productId: string, colorCode: string): void { this.activeProductColor.update((prev) => ({ ...prev, [productId]: colorCode })) } resetFilters(): void { this.selectedCategories.set([]) this.priceRange.set([50, 300]) this.selectedColors.set([]) this.selectedBrands.set([]) this.minRating.set(0) this.page.set(1) } getCategoryCount(catId: string): number { return PRODUCTS.filter((p) => p.categoryId === catId).length } getBrandCount(brand: string): number { return PRODUCTS.filter((p) => p.brand === brand).length } }src/app/components/blocks/product-grid-page/index.ts0.2 kB
export { UiProductGridPageComponent, CATEGORIES, BRANDS, COLORS, RATING_OPTIONS, type CategoryOption, type BrandOption, type ColorOption, } from './product-grid-page.component'
Raw manifest:https://uipkge.dev/r/angular/product-grid-page.json