Menu

Change language

GitHub repo

Product Grid Page

blockcommerce

E-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

$npx uipkge-ng@latest add product-grid-page
Manifest:https://uipkge.dev/r/angular/product-grid-page.jsonInstalls to:src/app/components/blocks/product-grid-page/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CategoryOption
interface CategoryOption {
  id: string
  label: string
}
BrandOption
interface BrandOption {
  id: string
  label: string
}
ColorOption
interface ColorOption {
  id: string
  label: string
  class: string
}
ProductColor
interface ProductColor {
  id: string
  name: string
  class: string
  colorCode: string
}
Product
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
}

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

Raw manifest:https://uipkge.dev/r/angular/product-grid-page.json