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 shadcn-svelte@latest add https://uipkge.dev/r/svelte/product-grid-page.json
Manifest:https://uipkge.dev/r/svelte/product-grid-page.jsonInstalls to:src/lib/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
}

Files installed (3)

  • src/lib/components/blocks/product-grid-page/ProductGridPage.svelte27.7 kB
    <script lang="ts">
      import {
        Check,
        ChevronLeft,
        ChevronRight,
        Heart,
        RotateCcw,
        ShoppingBag,
        SlidersHorizontal,
        Star,
        X,
      } from '@lucide/svelte'
      import { cn } from '$lib/utils'
      import { Badge } from '$lib/components/ui/badge'
      import {
        Breadcrumb,
        BreadcrumbItem,
        BreadcrumbLink,
        BreadcrumbList,
        BreadcrumbPage,
        BreadcrumbSeparator,
      } from '$lib/components/ui/breadcrumb'
      import { Button } from '$lib/components/ui/button'
      import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select'
      import { Separator } from '$lib/components/ui/separator'
      import {
        Sheet,
        SheetContent,
        SheetDescription,
        SheetHeader,
        SheetTitle,
        SheetTrigger,
      } from '$lib/components/ui/sheet'
      import ProductFilterSidebar from './ProductFilterSidebar.svelte'
      import { CATEGORIES, COLORS } from './product-filter-data'
    
      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
      }
    
      interface Props {
        class?: string
      }
    
      let { class: className }: Props = $props()
    
      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,
        },
      ]
    
      let selectedCategories: string[] = $state([])
      let priceRange: [number, number] = $state([50, 300])
      let selectedColors: string[] = $state([])
      let selectedBrands: string[] = $state([])
      let minRating = $state(0)
      let sortBy = $state('featured')
      let wishlistIds: Set<string> = $state(new Set(['prod-1', 'prod-5']))
      let addedToCartIds: Set<string> = $state(new Set())
      let activeProductColor: Record<string, string> = $state({})
      let page = $state(1)
      const totalPages = 3
      let isMobileFilterOpen = $state(false)
    
      function toggleCategory(id: string, checked: boolean) {
        if (checked) {
          selectedCategories = [...selectedCategories, id]
        } else {
          selectedCategories = selectedCategories.filter((item) => item !== id)
        }
        page = 1
      }
    
      function toggleBrand(id: string, checked: boolean) {
        if (checked) {
          selectedBrands = [...selectedBrands, id]
        } else {
          selectedBrands = selectedBrands.filter((item) => item !== id)
        }
        page = 1
      }
    
      function toggleColor(id: string) {
        if (selectedColors.includes(id)) {
          selectedColors = selectedColors.filter((item) => item !== id)
        } else {
          selectedColors = [...selectedColors, id]
        }
        page = 1
      }
    
      function setRating(val: number) {
        if (minRating === val) {
          minRating = 0
        } else {
          minRating = val
        }
        page = 1
      }
    
      function toggleWishlist(id: string) {
        const next = new Set(wishlistIds)
        if (next.has(id)) {
          next.delete(id)
        } else {
          next.add(id)
        }
        wishlistIds = next
      }
    
      function isWishlisted(id: string) {
        return wishlistIds.has(id)
      }
    
      function addToCart(id: string) {
        const next = new Set(addedToCartIds)
        next.add(id)
        addedToCartIds = next
        setTimeout(() => {
          const updated = new Set(addedToCartIds)
          updated.delete(id)
          addedToCartIds = updated
        }, 1600)
      }
    
      function isAddedToCart(id: string) {
        return addedToCartIds.has(id)
      }
    
      function selectProductColor(productId: string, colorCode: string) {
        activeProductColor = {
          ...activeProductColor,
          [productId]: colorCode,
        }
      }
    
      function resetFilters() {
        selectedCategories = []
        priceRange = [50, 300]
        selectedColors = []
        selectedBrands = []
        minRating = 0
        page = 1
      }
    
      const hasActiveFilters = $derived(
        selectedCategories.length > 0 ||
          selectedBrands.length > 0 ||
          selectedColors.length > 0 ||
          minRating > 0 ||
          priceRange[0] > 50 ||
          priceRange[1] < 300,
      )
    
      const activeFilterCount = $derived.by(() => {
        let count = 0
        if (selectedCategories.length > 0) count += selectedCategories.length
        if (selectedBrands.length > 0) count += selectedBrands.length
        if (selectedColors.length > 0) count += selectedColors.length
        if (minRating > 0) count += 1
        if (priceRange[0] > 50 || priceRange[1] < 300) count += 1
        return count
      })
    
      function getCategoryCount(catId: string) {
        return PRODUCTS.filter((p) => p.categoryId === catId).length
      }
    
      function getBrandCount(brand: string) {
        return PRODUCTS.filter((p) => p.brand === brand).length
      }
    
      const filteredProducts = $derived.by(() => {
        return PRODUCTS.filter((product) => {
          if (selectedCategories.length > 0 && !selectedCategories.includes(product.categoryId)) {
            return false
          }
          if (product.price < priceRange[0] || product.price > priceRange[1]) {
            return false
          }
          if (selectedColors.length > 0) {
            const hasMatchingColor = product.colors.some((c) => selectedColors.includes(c.colorCode))
            if (!hasMatchingColor) return false
          }
          if (selectedBrands.length > 0 && !selectedBrands.includes(product.brand)) {
            return false
          }
          if (minRating > 0 && product.rating < minRating) {
            return false
          }
          return true
        }).sort((a, b) => {
          if (sortBy === 'price-asc') return a.price - b.price
          if (sortBy === 'price-desc') return b.price - a.price
          if (sortBy === 'rating-desc') return b.rating - a.rating
          if (sortBy === 'newest') return b.createdAt - a.createdAt
          return a.featuredRank - b.featuredRank
        })
      })
    
      const pageNumbers = $derived(Array.from({ length: totalPages }, (_, i) => i + 1))
    </script>
    
    <div data-slot="product-grid-page" class={cn('w-full space-y-8', className)}>
      <!-- Breadcrumb -->
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#" class="inline-flex min-h-6 items-center">Home</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbLink href="#" class="inline-flex min-h-6 items-center">Shop</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbPage>Footwear</BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </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>
            <Badge variant="secondary" class="font-normal tabular-nums">{filteredProducts.length} items</Badge>
          </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 -->
          <Sheet bind:open={isMobileFilterOpen}>
            <SheetTrigger>
              {#snippet child({ props })}
                <Button variant="outline" size="sm" class="gap-2 md:hidden" {...props}>
                  <SlidersHorizontal class="size-4" />
                  <span>Filters</span>
                  {#if activeFilterCount > 0}
                    <Badge variant="secondary" class="ml-1 px-1.5 py-0 text-xs">
                      {activeFilterCount}
                    </Badge>
                  {/if}
                </Button>
              {/snippet}
            </SheetTrigger>
            <SheetContent side="left" class="w-full max-w-xs overflow-y-auto p-6">
              <SheetHeader class="text-left">
                <div class="flex items-center justify-between">
                  <SheetTitle class="text-base font-semibold">Filters</SheetTitle>
                  {#if hasActiveFilters}
                    <Button
                      variant="ghost"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
                      onclick={resetFilters}
                    >
                      Reset all
                    </Button>
                  {/if}
                </div>
                <SheetDescription class="text-xs">Narrow down footwear by category, price, and specs.</SheetDescription>
              </SheetHeader>
    
              <div class="mt-6">
                <ProductFilterSidebar
                  isMobile
                  {selectedCategories}
                  {priceRange}
                  {selectedColors}
                  {selectedBrands}
                  {minRating}
                  {getCategoryCount}
                  {getBrandCount}
                  onToggleCategory={toggleCategory}
                  onPriceRangeChange={(v) => (priceRange = v)}
                  onToggleColor={toggleColor}
                  onToggleBrand={toggleBrand}
                  onSelectRating={setRating}
                />
              </div>
            </SheetContent>
          </Sheet>
    
          <!-- Sort Select -->
          <div class="flex items-center gap-2">
            <span class="text-muted-foreground hidden text-xs sm:inline-block">Sort by:</span>
            <Select bind:value={sortBy}>
              <SelectTrigger class="h-9 w-44 text-xs sm:text-sm">
                <SelectValue placeholder="Sort by" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="featured">Featured</SelectItem>
                <SelectItem value="price-asc">Price: Low to High</SelectItem>
                <SelectItem value="price-desc">Price: High to Low</SelectItem>
                <SelectItem value="rating-desc">Highest Rated</SelectItem>
                <SelectItem value="newest">Newest</SelectItem>
              </SelectContent>
            </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
                variant="ghost"
                size="sm"
                class="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
                onclick={resetFilters}
              >
                Reset all
              </Button>
            {/if}
          </div>
    
          <Separator />
    
          <ProductFilterSidebar
            {selectedCategories}
            {priceRange}
            {selectedColors}
            {selectedBrands}
            {minRating}
            {getCategoryCount}
            {getBrandCount}
            onToggleCategory={toggleCategory}
            onPriceRangeChange={(v) => (priceRange = v)}
            onToggleColor={toggleColor}
            onToggleBrand={toggleBrand}
            onSelectRating={setRating}
          />
        </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">
              {#each selectedCategories as catId (catId)}
                <Badge variant="secondary" class="gap-1.5 py-1 text-xs">
                  <span>Category: {CATEGORIES.find((c) => c.id === catId)?.label}</span>
                  <button
                    type="button"
                    class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                    aria-label="Remove category filter"
                    onclick={() => toggleCategory(catId, false)}
                  >
                    <X class="size-3" />
                  </button>
                </Badge>
              {/each}
    
              {#each selectedBrands as brand (brand)}
                <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"
                    onclick={() => toggleBrand(brand, false)}
                  >
                    <X class="size-3" />
                  </button>
                </Badge>
              {/each}
    
              {#each selectedColors as colorId (colorId)}
                <Badge variant="secondary" class="gap-1.5 py-1 text-xs">
                  <span>Color: {COLORS.find((c) => c.id === colorId)?.label}</span>
                  <button
                    type="button"
                    class="hover:text-foreground text-muted-foreground focus-visible:outline-none"
                    aria-label="Remove color filter"
                    onclick={() => toggleColor(colorId)}
                  >
                    <X class="size-3" />
                  </button>
                </Badge>
              {/each}
    
              {#if minRating > 0}
                <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"
                    onclick={() => (minRating = 0)}
                  >
                    <X class="size-3" />
                  </button>
                </Badge>
              {/if}
    
              {#if priceRange[0] > 50 || priceRange[1] < 300}
                <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"
                    onclick={() => (priceRange = [50, 300])}
                  >
                    <X class="size-3" />
                  </button>
                </Badge>
              {/if}
    
              <Button
                variant="link"
                size="sm"
                class="text-muted-foreground hover:text-foreground h-auto px-1 text-xs"
                onclick={resetFilters}
              >
                Clear all
              </Button>
            </div>
          {/if}
    
          <!-- 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">
                <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 variant="outline" size="sm" class="mt-4 gap-2" onclick={resetFilters}>
                <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">
              {#each filteredProducts as product (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"
                >
                  <!-- Image Aspect Square Container -->
                  <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"
                    />
    
                    <!-- Top Left Badge -->
                    {#if product.badge}
                      <div class="absolute top-2.5 left-2.5 z-10">
                        <Badge variant={product.badge.variant} class="text-xs shadow-xs">
                          {product.badge.label}
                        </Badge>
                      </div>
                    {/if}
    
                    <!-- Top Right Wishlist Heart -->
                    <button
                      type="button"
                      aria-label="Toggle wishlist"
                      class={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',
                        isWishlisted(product.id) && 'text-destructive hover:text-destructive',
                      )}
                      onclick={(e) => {
                        e.stopPropagation()
                        toggleWishlist(product.id)
                      }}
                    >
                      <Heart
                        class={cn(
                          'size-4 transition-transform active:scale-90',
                          isWishlisted(product.id) && 'fill-current',
                        )}
                      />
                    </button>
    
                    <!-- Quick Add Button (Visible on hover & focus) -->
                    <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
                        size="sm"
                        class="w-full gap-1.5 text-xs font-medium shadow-sm"
                        onclick={(e) => {
                          e.stopPropagation()
                          addToCart(product.id)
                        }}
                      >
                        {#if isAddedToCart(product.id)}
                          <Check class="size-3.5" />
                        {:else}
                          <ShoppingBag class="size-3.5" />
                        {/if}
                        <span>{isAddedToCart(product.id) ? 'Added to Cart' : 'Quick Add'}</span>
                      </Button>
                    </div>
                  </div>
    
                  <!-- Card Body -->
                  <div class="mt-3 flex flex-1 flex-col justify-between space-y-2">
                    <div>
                      <!-- Color swatches preview -->
                      <div class="flex items-center gap-1.5">
                        {#each product.colors as color (color.id)}
                          <button
                            type="button"
                            title={color.name}
                            class={cn(
                              'size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none',
                              color.class,
                              activeProductColor[product.id] === color.colorCode
                                ? 'ring-1.5 ring-primary ring-offset-background ring-offset-1'
                                : '',
                            )}
                            onclick={(e) => {
                              e.stopPropagation()
                              selectProductColor(product.id, color.colorCode)
                            }}
                          >
                            <span class="sr-only">{color.name}</span>
                          </button>
                        {/each}
                        <span class="text-muted-foreground ml-1 text-xs">
                          {product.colors.length} {product.colors.length === 1 ? 'color' : 'colors'}
                        </span>
                      </div>
    
                      <!-- Brand & Category -->
                      <div class="text-muted-foreground mt-2 flex items-center justify-between text-xs">
                        <span>{product.brand} · {product.category}</span>
                        <div class="flex items-center gap-1">
                          <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>
                        </div>
                      </div>
    
                      <!-- Title -->
                      <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>
    
                    <!-- Price Row -->
                    <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>
                      {/if}
                      {#if product.originalPrice}
                        <span class="text-success text-xs font-medium">
                          Save ${(product.originalPrice - product.price).toFixed(2)}
                        </span>
                      {/if}
                    </div>
                  </div>
                </div>
              {/each}
            </div>
          {/if}
    
          <!-- Pagination Footer -->
          {#if filteredProducts.length > 0}
            <nav class="border-border flex items-center justify-between border-t pt-6" aria-label="Pagination">
              <Button
                variant="outline"
                size="sm"
                class="gap-1 text-xs"
                disabled={page === 1}
                onclick={() => (page -= 1)}
              >
                <ChevronLeft class="size-4" />
                <span>Previous</span>
              </Button>
    
              <div class="flex items-center gap-1">
                {#each pageNumbers as n (n)}
                  <Button
                    variant={n === page ? 'outline' : 'ghost'}
                    size="sm"
                    class="size-8 p-0 text-xs tabular-nums"
                    aria-current={n === page ? 'page' : undefined}
                    onclick={() => (page = n)}
                  >
                    {n}
                  </Button>
                {/each}
              </div>
    
              <Button
                variant="outline"
                size="sm"
                class="gap-1 text-xs"
                disabled={page === totalPages}
                onclick={() => (page += 1)}
              >
                <span>Next</span>
                <ChevronRight class="size-4" />
              </Button>
            </nav>
          {/if}
        </section>
      </div>
    </div>
    
  • src/lib/components/blocks/product-grid-page/ProductFilterSidebar.svelte6.2 kB
  • src/lib/components/blocks/product-grid-page/product-filter-data.ts1.2 kB

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